From 543032b28d8bf8aa9a2a61beab18cff2cd460b83 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Fri, 30 Oct 2026 18:19:46 +0700 Subject: [PATCH 1/3] MM-71467 Add Playwright E2E tests: contract-checked fake GitHub on every PR, real GitHub nightly --- .github/workflows/e2e.yml | 204 + build/custom.mk | 27 + e2e/integration/.env.example | 22 + e2e/integration/.gitignore | 6 + e2e/integration/README.md | 59 + e2e/integration/docker-compose.yml | 62 + .../fake-github/contract/operations.json | 62 + .../fake-github/contract/specs/rest.json | 19416 ++++++++++++++++ .../fake-github/contract/specs/schema.graphql | 153 + .../fake-github/contract/update_specs.mjs | 193 + .../fake-github/contract/validator.ts | 220 + e2e/integration/fake-github/server.ts | 821 + e2e/integration/fake-github/state.ts | 467 + e2e/integration/fake-github/webhooks.ts | 128 + e2e/integration/global_setup.ts | 20 + e2e/integration/package-lock.json | 1954 ++ e2e/integration/package.json | 30 + e2e/integration/playwright.config.ts | 49 + e2e/integration/playwright.real.config.ts | 42 + e2e/integration/real-github/api.ts | 57 + e2e/integration/real-github/env.ts | 46 + e2e/integration/real-github/fixture.ts | 45 + e2e/integration/real-github/fixtures.ts | 99 + e2e/integration/real-github/global_setup.ts | 28 + e2e/integration/real-github/journey.spec.ts | 153 + .../real-github/login/github_login.ts | 55 + e2e/integration/real-github/login/totp.ts | 37 + e2e/integration/real-github/rate_limit.ts | 42 + e2e/integration/scripts/visual.sh | 18 + e2e/integration/support/admin.ts | 12 + e2e/integration/support/constants.ts | 43 + e2e/integration/support/fake_github.ts | 48 + e2e/integration/support/fixture.ts | 78 + e2e/integration/support/kv.ts | 105 + e2e/integration/support/layout.ts | 47 + e2e/integration/support/plugin.ts | 94 + e2e/integration/support/seed.ts | 25 + e2e/integration/support/ui.ts | 92 + e2e/integration/support/visual.css | 4 + .../visual.spec.ts/attach-issue-modal.png | Bin 0 -> 20631 bytes .../visual.spec.ts/create-issue-modal.png | Bin 0 -> 34061 bytes .../visual.spec.ts/profile-popover.png | Bin 0 -> 1437 bytes .../visual.spec.ts/reply-thread.png | Bin 0 -> 24959 bytes .../visual.spec.ts/rhs-assignments.png | Bin 0 -> 16203 bytes .../visual.spec.ts/rhs-open-prs.png | Bin 0 -> 25416 bytes .../visual.spec.ts/rhs-review-requests.png | Bin 0 -> 19211 bytes .../visual.spec.ts/rhs-unreads.png | Bin 0 -> 18813 bytes .../visual.spec.ts/sidebar-buttons.png | Bin 0 -> 1795 bytes .../__screenshots__/visual.spec.ts/todo.png | Bin 0 -> 31432 bytes .../visual.spec.ts/tooltip-issue.png | Bin 0 -> 7756 bytes .../visual.spec.ts/tooltip-pull.png | Bin 0 -> 18203 bytes .../visual.spec.ts/webhook-issue-comment.png | Bin 0 -> 12771 bytes .../visual.spec.ts/webhook-merged-pull.png | Bin 0 -> 11640 bytes .../visual.spec.ts/webhook-new-issue.png | Bin 0 -> 11338 bytes .../visual.spec.ts/webhook-new-pull.png | Bin 0 -> 24609 bytes .../visual.spec.ts/webhook-push.png | Bin 0 -> 7971 bytes e2e/integration/tests/connect.spec.ts | 140 + e2e/integration/tests/contract.spec.ts | 206 + e2e/integration/tests/issues.spec.ts | 108 + e2e/integration/tests/sidebar.spec.ts | 78 + e2e/integration/tests/smoke.spec.ts | 16 + e2e/integration/tests/todo.spec.ts | 35 + e2e/integration/tests/ui_layout.spec.ts | 105 + e2e/integration/tests/visual.spec.ts | 191 + e2e/integration/tests/webhooks.spec.ts | 146 + e2e/integration/tsconfig.json | 16 + .../mock_oauth_server/mock_oauth_server.ts | 29 + server/plugin/graphql/client.go | 39 +- server/plugin/plugin.go | 22 +- server/plugin/plugin_test.go | 15 + .../github_repo_selector.jsx | 6 +- 71 files changed, 26191 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/integration/.env.example create mode 100644 e2e/integration/.gitignore create mode 100644 e2e/integration/README.md create mode 100644 e2e/integration/docker-compose.yml create mode 100644 e2e/integration/fake-github/contract/operations.json create mode 100644 e2e/integration/fake-github/contract/specs/rest.json create mode 100644 e2e/integration/fake-github/contract/specs/schema.graphql create mode 100644 e2e/integration/fake-github/contract/update_specs.mjs create mode 100644 e2e/integration/fake-github/contract/validator.ts create mode 100644 e2e/integration/fake-github/server.ts create mode 100644 e2e/integration/fake-github/state.ts create mode 100644 e2e/integration/fake-github/webhooks.ts create mode 100644 e2e/integration/global_setup.ts create mode 100644 e2e/integration/package-lock.json create mode 100644 e2e/integration/package.json create mode 100644 e2e/integration/playwright.config.ts create mode 100644 e2e/integration/playwright.real.config.ts create mode 100644 e2e/integration/real-github/api.ts create mode 100644 e2e/integration/real-github/env.ts create mode 100644 e2e/integration/real-github/fixture.ts create mode 100644 e2e/integration/real-github/fixtures.ts create mode 100644 e2e/integration/real-github/global_setup.ts create mode 100644 e2e/integration/real-github/journey.spec.ts create mode 100644 e2e/integration/real-github/login/github_login.ts create mode 100644 e2e/integration/real-github/login/totp.ts create mode 100644 e2e/integration/real-github/rate_limit.ts create mode 100755 e2e/integration/scripts/visual.sh create mode 100644 e2e/integration/support/admin.ts create mode 100644 e2e/integration/support/constants.ts create mode 100644 e2e/integration/support/fake_github.ts create mode 100644 e2e/integration/support/fixture.ts create mode 100644 e2e/integration/support/kv.ts create mode 100644 e2e/integration/support/layout.ts create mode 100644 e2e/integration/support/plugin.ts create mode 100644 e2e/integration/support/seed.ts create mode 100644 e2e/integration/support/ui.ts create mode 100644 e2e/integration/support/visual.css create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/create-issue-modal.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/profile-popover.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/reply-thread.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-assignments.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-open-prs.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-review-requests.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-unreads.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/sidebar-buttons.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/todo.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-issue.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-pull.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-issue-comment.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-merged-pull.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-issue.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-pull.png create mode 100644 e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-push.png create mode 100644 e2e/integration/tests/connect.spec.ts create mode 100644 e2e/integration/tests/contract.spec.ts create mode 100644 e2e/integration/tests/issues.spec.ts create mode 100644 e2e/integration/tests/sidebar.spec.ts create mode 100644 e2e/integration/tests/smoke.spec.ts create mode 100644 e2e/integration/tests/todo.spec.ts create mode 100644 e2e/integration/tests/ui_layout.spec.ts create mode 100644 e2e/integration/tests/visual.spec.ts create mode 100644 e2e/integration/tests/webhooks.spec.ts create mode 100644 e2e/integration/tsconfig.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..f09994798 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,204 @@ +name: e2e +on: + schedule: + - cron: "0 3 * * *" + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + playwright: + name: playwright (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: min-server + image: mattermost/mattermost-enterprise-edition:release-12.0 + - name: master + image: mattermostdevelopment/mattermost-enterprise-edition:master + env: + CI: true + MM_IMAGE: ${{ matrix.image }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: | + webapp/package-lock.json + e2e/integration/package-lock.json + + - name: Build E2E plugin bundle + run: make e2e-dist + env: + # Builds only for the runner's OS/arch. + MM_SERVICESETTINGS_ENABLEDEVELOPER: true + + - name: Install e2e dependencies + working-directory: e2e/integration + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Type-check e2e + working-directory: e2e/integration + run: npm run tsc + + - name: Start Mattermost + working-directory: e2e/integration + run: npm run env:up + + - name: Run Playwright + working-directory: e2e/integration + run: npm run test:ci + + - name: Collect server logs + if: failure() + working-directory: e2e/integration + run: mkdir -p playwright-report && docker compose logs mattermost > playwright-report/mattermost.log 2>&1 || true + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.name }} + path: e2e/integration/playwright-report + retention-days: 14 + + # Non-blocking: a screenshot diff needs a person to judge whether the change is intended. + visual: + name: playwright visual + runs-on: ubuntu-latest + timeout-minutes: 20 + continue-on-error: true + env: + CI: true + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: | + webapp/package-lock.json + e2e/integration/package-lock.json + + - name: Build E2E plugin bundle + run: make e2e-dist + env: + MM_SERVICESETTINGS_ENABLEDEVELOPER: true + + - name: Install e2e dependencies + working-directory: e2e/integration + run: npm ci + + - name: Compare screenshots + working-directory: e2e/integration + run: npm run test:visual + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-visual-diffs + path: | + e2e/integration/playwright-report + e2e/integration/test-results + retention-days: 14 + + contract-drift: + name: fake-github contract drift + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Compare committed specs with GitHub's published REST description and GraphQL schema + working-directory: e2e/integration + run: npm run contract:check + + real-github: + name: real github.com journey + if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && vars.REAL_GITHUB_E2E_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + # The fixtures and hook on the test repository are shared between runs. + concurrency: + group: real-github-e2e + cancel-in-progress: false + env: + CI: true + E2E_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_ID }} + E2E_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_SECRET }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_MACHINE_TOKEN: ${{ secrets.E2E_GITHUB_MACHINE_TOKEN }} + E2E_GITHUB_LOGIN_USERNAME: ${{ secrets.E2E_GITHUB_LOGIN_USERNAME }} + E2E_GITHUB_LOGIN_PASSWORD: ${{ secrets.E2E_GITHUB_LOGIN_PASSWORD }} + E2E_GITHUB_LOGIN_TOTP_SECRET: ${{ secrets.E2E_GITHUB_LOGIN_TOTP_SECRET }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: | + webapp/package-lock.json + e2e/integration/package-lock.json + + - name: Build plugin bundle + run: make dist + env: + MM_SERVICESETTINGS_ENABLEDEVELOPER: true + + - name: Install e2e dependencies + working-directory: e2e/integration + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Start Mattermost + working-directory: e2e/integration + run: npm run env:up + + # No report upload: it would carry real tokens and page content. + - name: Run journey + working-directory: e2e/integration + run: npm run test:real diff --git a/build/custom.mk b/build/custom.mk index c1cb882cd..a5407a8ab 100644 --- a/build/custom.mk +++ b/build/custom.mk @@ -6,3 +6,30 @@ endif .PHONY: deploy-e2e deploy-e2e: dist E2E_TESTING=true PLUGIN_E2E_MOCK_OAUTH_SERVER_URL=http://localhost:8080 ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) + +E2E_DIR = e2e/integration +E2E_FAKE_GITHUB_URL ?= http://fake-github:8093 + +.PHONY: e2e-dist +e2e-dist: ## Builds dist/ with GitHub OAuth, REST and GraphQL pointed at the E2E fake. Never ship this bundle. + $(MAKE) dist PLUGIN_E2E_MOCK_OAUTH_SERVER_URL=$(E2E_FAKE_GITHUB_URL) + +$(E2E_DIR)/node_modules: $(E2E_DIR)/package-lock.json + cd $(E2E_DIR) && $(NPM) ci && npx playwright install chromium + touch $@ + +.PHONY: e2e-env-up +e2e-env-up: ## Starts the Mattermost stack used by the E2E suite + cd $(E2E_DIR) && docker compose up -d --wait + +.PHONY: e2e-env-down +e2e-env-down: ## Stops the E2E Mattermost stack and drops its data + cd $(E2E_DIR) && docker compose --profile visual down -v + +.PHONY: e2e +e2e: e2e-dist $(E2E_DIR)/node_modules ## Runs the fake-GitHub E2E suite against the stack from e2e-env-up + cd $(E2E_DIR) && npx playwright test + +.PHONY: e2e-real +e2e-real: dist $(E2E_DIR)/node_modules ## Runs the real GitHub journey (needs e2e/integration/.env) + cd $(E2E_DIR) && npm run test:real diff --git a/e2e/integration/.env.example b/e2e/integration/.env.example new file mode 100644 index 000000000..04510236e --- /dev/null +++ b/e2e/integration/.env.example @@ -0,0 +1,22 @@ +# Copy to .env. docker compose and the tests both read it. +# Values for the real lane live in 1Password (search "GitHub plugin E2E"); never paste them into chats, issues or PRs. + +# Stack (optional). Defaults avoid the ports other plugin E2E stacks use. +# MM_PORT=8069 +# PW_BROWSER_PORT=3336 +# MM_IMAGE=mattermostdevelopment/mattermost-enterprise-edition:master + +# Real lane only (npm run test:real). The PR lane needs none of these. +# A classic OAuth app whose callback URL is http://127.0.0.1:${MM_PORT}/plugins/github/oauth/complete +E2E_GITHUB_OAUTH_CLIENT_ID= +E2E_GITHUB_OAUTH_CLIENT_SECRET= +# owner/name of a throwaway repository in a test organization. The machine account needs admin on it. +E2E_GITHUB_REPO= +# OAuth token (or classic personal access token) of the machine account. +# Scopes: repo, notifications, read:org, admin:org_hook, admin:repo_hook +E2E_GITHUB_MACHINE_TOKEN= + +# Optional: the real sign-in test skips without these. The account needs 2FA with an authenticator app. +E2E_GITHUB_LOGIN_USERNAME= +E2E_GITHUB_LOGIN_PASSWORD= +E2E_GITHUB_LOGIN_TOTP_SECRET= diff --git a/e2e/integration/.gitignore b/e2e/integration/.gitignore new file mode 100644 index 000000000..28f0a2d6f --- /dev/null +++ b/e2e/integration/.gitignore @@ -0,0 +1,6 @@ +node_modules +test-results +playwright-report +storage_state +.env +.auth diff --git a/e2e/integration/README.md b/e2e/integration/README.md new file mode 100644 index 000000000..4f340569e --- /dev/null +++ b/e2e/integration/README.md @@ -0,0 +1,59 @@ +# GitHub plugin Playwright tests + +Two lanes. The fake lane runs on every PR, including forks, with no secrets. The real lane talks to github.com and runs nightly only when enabled. + +The older suite in `e2e/playwright` (and `playwright.yml`) is unchanged and still runs against its own mock. + +```sh +make e2e-env-up # Postgres + Mattermost on :8069 +make e2e # builds the E2E bundle (make e2e-dist), installs the plugin, runs the suite +make e2e-env-down + +make e2e-real # normal bundle, against github.com. Needs e2e/integration/.env +``` + +`make e2e-dist` bakes `http://fake-github:8093` into `testOAuthServerURL`, which now moves the OAuth, REST, GraphQL and upload base URLs together, so the github.com code path is what gets tested. The plugin logs a warning on activation. Never ship that bundle. + +Overrides go in the shell or in `e2e/integration/.env` (copy `.env.example`; docker compose reads it too). Real values live in 1Password or GitHub Actions secrets. + +| Variable | Default | Purpose | +|---|---|---| +| `MM_IMAGE` | `mattermostdevelopment/mattermost-enterprise-edition:master` | Server image | +| `MM_PORT` | `8069` | Host port; must match between `e2e-env-up` and `e2e` | +| `PW_BROWSER_PORT` | `3336` | Host port of the dockerized visual browser | +| `PW_HEADLESS` | `true` | `false` to watch the browser | + +## How GitHub is faked + +- `fake-github/server.ts` runs inside the Playwright process on port 8093. Mattermost reaches it as `fake-github` through `extra_hosts`; Chromium through `--host-resolver-rules`. +- It is a classic OAuth app: tokens never expire and have no refresh token. Token errors come back as HTTP 200 with an `error` field, as form or JSON depending on `Accept`. Scopes from earlier grants are merged, and consent is skipped when an earlier grant already covers the request. +- Permissions follow GitHub: private repos need `repo`; repo hooks need repo admin plus `repo` or a `*:repo_hook` scope, otherwise 404; org hooks need org admin plus `admin:org_hook`; notifications need `notifications` or `repo`; issue labels, assignees and milestone are silently dropped without push access; SAML-enforced orgs answer 403 with `X-GitHub-SSO`. +- Creating an issue fires `issues.opened` to matching hooks and creating a hook sends `ping`, signed with `X-Hub-Signature` (HMAC-SHA1) and `X-Hub-Signature-256`. +- Tests drive it through `support/fake_github.ts` (`reset`, `createRepo`, `createItem`, `webhook`, `fault`, `sso`, `revoke`, `requests`, ...). +- Only `connect.spec.ts` drives the browser OAuth flow. Other specs connect with `seedUserInfo()` (`support/kv.ts`), which writes `_githubtoken` with the plugin's AES-CFB cipher. `installPlugin()` also seeds `mm34646_token_reset_done`, or the plugin resets every stored token a minute after it starts. +- Don't use `pw.initSetup()`: it resets server config and disables the plugin. + +## Keeping the fake honest + +Every request and response is checked, and each test fails at teardown if anything broke the contract. + +- **REST:** validated against [github/rest-api-description](https://github.com/github/rest-api-description) (`api.github.com.json`), trimmed to the operations in `fake-github/contract/operations.json`. A route that isn't listed fails the run. +- **GraphQL:** queries are validated and executed against GitHub's [public schema](https://docs.github.com/en/graphql/overview/public-schema), trimmed to the types the plugin uses. graphql-js rejects `... on Issue { state } ... on PullRequest { state }` as a field conflict while GitHub answers it, so that one message is ignored. The real lane checks GitHub still accepts it. +- **Webhooks:** validated against the `x-webhooks` schemas in the same REST description. These replaced [octokit/webhooks](https://github.com/octokit/webhooks), which is deprecated and no longer updated. +- Nightly, `npm run contract:check` fails if GitHub changed the committed specs. `npm run contract:update` refreshes them. + +## Real GitHub + +`npm run test:real` (or `make e2e-real`) needs the variables in `.env.example`. Global setup: + +- Creates long-lived fixtures once and reuses them: an `e2e-fixture` label, an open issue assigned to the machine account, and an open pull request from the `e2e-fixture` branch. It then polls search until both show up, because the plugin finds them through search and the index lags. +- Points a repository hook at a public placeholder URL with a fresh secret. GitHub refuses hooks to private addresses, so tests fetch the recorded delivery from the hook deliveries API and post it to the plugin. +- Seeds the machine account's token into KV, so most tests skip the browser login. + +The sign-in test logs in with password and TOTP, then revokes its grant so the next run starts clean. GitHub skips its emailed device check for accounts with 2FA. + +Writes go through `real-github/rate_limit.ts`, which stays under GitHub's content limits (80 a minute, 500 an hour) across runs on one machine. Retries are off so failures don't repeat writes. Traces, screenshots and video are off when `CI` is set. Don't put account, organization or repository names in this tree. + +## Screenshots + +`npm run test:visual` compares `@visual` shots in a dockerized browser against a pinned server image, in its own throwaway stack, and does not fail the PR job. Update baselines with `npm run test:visual-update-snapshots` and commit the PNGs. diff --git a/e2e/integration/docker-compose.yml b/e2e/integration/docker-compose.yml new file mode 100644 index 000000000..1bd3d7745 --- /dev/null +++ b/e2e/integration/docker-compose.yml @@ -0,0 +1,62 @@ +name: github-plugin-e2e + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: mmuser + POSTGRES_PASSWORD: mostest + POSTGRES_DB: mattermost_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost_test"] + interval: 5s + timeout: 5s + retries: 10 + tmpfs: + - /var/lib/postgresql/data + + mattermost: + image: ${MM_IMAGE:-mattermostdevelopment/mattermost-enterprise-edition:master} + # Server images are published for amd64 only; Apple Silicon runs them under emulation. + platform: linux/amd64 + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:${MM_PORT:-8069}:${MM_PORT:-8069}" + - "127.0.0.1:${PW_BROWSER_PORT:-3336}:3333" + extra_hosts: + - "fake-github:host-gateway" + environment: + MM_SQLSETTINGS_DRIVERNAME: postgres + MM_SQLSETTINGS_DATASOURCE: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10 + MM_SERVICESETTINGS_SITEURL: http://127.0.0.1:${MM_PORT:-8069} + MM_SERVICESETTINGS_LISTENADDRESS: ":${MM_PORT:-8069}" + MM_SERVICESETTINGS_ENABLELOCALMODE: "true" + MM_PLUGINSETTINGS_ENABLE: "true" + MM_PLUGINSETTINGS_ENABLEUPLOADS: "true" + MM_PLUGINSETTINGS_AUTOMATICPREPACKAGEDPLUGINS: "false" + MM_TEAMSETTINGS_ENABLEOPENSERVER: "true" + MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false" + MM_ANNOUNCEMENTSETTINGS_USERNOTICESENABLED: "false" + MM_LOGSETTINGS_CONSOLELEVEL: INFO + healthcheck: + test: ["CMD", "/mattermost/bin/mmctl", "--local", "system", "status"] + interval: 5s + timeout: 5s + retries: 60 + + # Shares Mattermost's network so the page origin matches the URL the tests use from the host. + visual-browser: + profiles: [visual] + image: mcr.microsoft.com/playwright:v${PW_VERSION:-1.64.1}-noble + network_mode: service:mattermost + # Chromium rasterizes text differently per CPU architecture; CI runners are amd64. + platform: linux/amd64 + init: true + command: npx -y playwright@${PW_VERSION:-1.64.1} run-server --port 3333 --host 0.0.0.0 + healthcheck: + test: ["CMD", "node", "-e", "require('net').connect(3333, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 60 diff --git a/e2e/integration/fake-github/contract/operations.json b/e2e/integration/fake-github/contract/operations.json new file mode 100644 index 000000000..45cf4f222 --- /dev/null +++ b/e2e/integration/fake-github/contract/operations.json @@ -0,0 +1,62 @@ +{ + "rest": { + "url": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", + "operations": [ + "GET /user", + "GET /users/{username}", + "GET /user/orgs", + "GET /user/repos", + "GET /users/{username}/repos", + "GET /orgs/{org}", + "GET /orgs/{org}/repos", + "GET /orgs/{org}/members/{username}", + "GET /orgs/{org}/public_members/{username}", + "GET /orgs/{org}/hooks", + "POST /orgs/{org}/hooks", + "GET /repos/{owner}/{repo}", + "GET /repos/{owner}/{repo}/hooks", + "POST /repos/{owner}/{repo}/hooks", + "GET /repos/{owner}/{repo}/issues/{issue_number}", + "POST /repos/{owner}/{repo}/issues", + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + "GET /repos/{owner}/{repo}/labels", + "GET /repos/{owner}/{repo}/assignees", + "GET /repos/{owner}/{repo}/milestones", + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews", + "GET /repos/{owner}/{repo}/commits/{ref}/status", + "GET /search/issues", + "GET /notifications" + ], + "webhooks": [ + "ping", + "pull-request-opened", + "pull-request-closed", + "pull-request-review-requested", + "issues-opened", + "issue-comment-created", + "push" + ] + }, + "graphql": { + "url": "https://docs.github.com/public/fpt/schema.docs.graphql", + "fields": { + "Query": ["search"], + "SearchResultItemConnection": ["issueCount", "nodes", "pageInfo"], + "PageInfo": ["endCursor", "hasNextPage"], + "PullRequest": ["additions", "author", "authorAssociation", "body", "changedFiles", "createdAt", "deletions", "labels", "milestone", "number", "repository", "reviewRequests", "state", "title", "updatedAt", "url"], + "Issue": ["author", "authorAssociation", "body", "createdAt", "labels", "milestone", "number", "repository", "state", "title", "updatedAt", "url"], + "Actor": ["login"], + "User": ["login"], + "Team": ["slug", "organization"], + "Organization": ["login"], + "Repository": ["name", "nameWithOwner", "owner", "url"], + "RepositoryOwner": ["login"], + "LabelConnection": ["nodes"], + "Label": ["color", "name"], + "Milestone": ["title"], + "ReviewRequestConnection": ["nodes"], + "ReviewRequest": ["requestedReviewer"] + } + } +} diff --git a/e2e/integration/fake-github/contract/specs/rest.json b/e2e/integration/fake-github/contract/specs/rest.json new file mode 100644 index 000000000..7c877c9e9 --- /dev/null +++ b/e2e/integration/fake-github/contract/specs/rest.json @@ -0,0 +1,19416 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "GitHub v3 REST API" + }, + "paths": { + "/user": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/private-user" + }, + { + "$ref": "#/components/schemas/public-user" + } + ], + "discriminator": { + "propertyName": "user_view_type", + "mapping": { + "public": "#/components/schemas/public-user", + "private": "#/components/schemas/private-user" + } + } + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + } + } + } + }, + "/users/{username}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/private-user" + }, + { + "$ref": "#/components/schemas/public-user" + } + ], + "discriminator": { + "propertyName": "user_view_type", + "mapping": { + "public": "#/components/schemas/public-user", + "private": "#/components/schemas/private-user" + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/user/orgs": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/organization-simple" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + } + } + } + }, + "/user/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/users/{username}/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/minimal-repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/orgs/{org}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/organization-full" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/orgs/{org}/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/minimal-repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/orgs/{org}/members/{username}": { + "get": { + "responses": { + "204": {}, + "302": { + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": {} + } + } + }, + "/orgs/{org}/public_members/{username}": { + "get": { + "responses": { + "204": {}, + "404": {} + } + } + }, + "/orgs/{org}/hooks": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/org-hook" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + }, + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "events": { + "type": "array", + "default": [ + "push" + ], + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name", + "config" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/org-hook" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/full-repository" + } + } + } + }, + "301": { + "$ref": "#/components/responses/moved_permanently" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/hooks": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/hook" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + }, + "post": { + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + } + } + }, + "events": { + "type": "array", + "default": [ + "push" + ], + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/hook" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{issue_number}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue" + } + } + } + }, + "301": { + "$ref": "#/components/responses/moved_permanently" + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + } + } + } + }, + "/repos/{owner}/{repo}/issues": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "body": { + "type": "string" + }, + "assignee": { + "type": "string", + "nullable": true + }, + "milestone": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "nullable": true + }, + "labels": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "nullable": true + } + } + } + ] + } + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "issue_field_values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "integer" + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "field_id", + "value" + ], + "additionalProperties": false + } + }, + "type": { + "type": "string", + "nullable": true + }, + "parent_issue_id": { + "type": "integer" + } + }, + "required": [ + "title" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad_request" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{issue_number}/comments": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "required": [ + "body" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue-comment" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}/labels": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/label" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/assignees": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/milestones": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/milestone" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/pulls/{pull_number}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pull-request" + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "406": { + "$ref": "#/components/responses/unacceptable" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "500": { + "$ref": "#/components/responses/internal_error" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/repos/{owner}/{repo}/pulls/{pull_number}/reviews": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/pull-request-review" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/repos/{owner}/{repo}/commits/{ref}/status": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/combined-commit-status" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/search/issues": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "total_count", + "incomplete_results", + "items", + "search_type" + ], + "properties": { + "total_count": { + "type": "integer" + }, + "incomplete_results": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-search-result-item" + } + }, + "search_type": { + "type": "string", + "enum": [ + "lexical", + "semantic", + "hybrid" + ] + }, + "lexical_fallback_reason": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "no_text_terms", + "quoted_text", + "non_issue_target", + "or_boolean_not_supported", + "no_accessible_repos", + "server_error", + "only_non_semantic_fields_requested", + "service_unavailable" + ] + } + } + } + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/notifications": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/thread" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + } + }, + "x-webhooks": { + "ping": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-ping" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/webhook-ping-form-encoded" + } + } + } + } + } + }, + "pull-request-opened": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-opened" + } + } + } + } + } + }, + "pull-request-closed": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-closed" + } + } + } + } + } + }, + "pull-request-review-requested": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-review-requested" + } + } + } + } + } + }, + "issues-opened": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-issues-opened" + } + } + } + } + } + }, + "issue-comment-created": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-issue-comment-created" + } + } + } + } + } + }, + "push": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-push" + } + } + } + } + } + } + }, + "components": { + "headers": { + "link": { + "schema": { + "type": "string" + } + } + }, + "responses": { + "bad_request": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + }, + "application/scim+json": { + "schema": { + "$ref": "#/components/schemas/scim-error" + } + } + } + }, + "forbidden": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "gone": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "internal_error": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "moved_permanently": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "not_found": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "not_modified": {}, + "requires_authentication": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "service_unavailable": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + } + } + } + } + } + }, + "unacceptable": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "validation_failed": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/validation-error" + } + } + } + } + }, + "schemas": { + "author-association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto-merge": { + "type": "object", + "properties": { + "enabled_by": { + "$ref": "#/components/schemas/simple-user" + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + "commit_title": { + "type": "string" + }, + "commit_message": { + "type": "string" + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ], + "nullable": true + }, + "basic-error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "code-of-conduct": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "url", + "html_url", + "key", + "name" + ] + }, + "code-of-conduct-simple": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "html_url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "url", + "key", + "name", + "html_url" + ] + }, + "combined-commit-status": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-commit-status" + } + }, + "sha": { + "type": "string" + }, + "total_count": { + "type": "integer" + }, + "repository": { + "$ref": "#/components/schemas/minimal-repository" + }, + "commit_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "state", + "sha", + "total_count", + "statuses", + "repository", + "commit_url", + "url" + ] + }, + "enterprise": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "website_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "avatar_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "name", + "slug", + "html_url", + "created_at", + "updated_at", + "avatar_url" + ] + }, + "enterprise-webhooks": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "website_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "avatar_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "name", + "slug", + "html_url", + "created_at", + "updated_at", + "avatar_url" + ] + }, + "full-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "template_repository": { + "$ref": "#/components/schemas/nullable-repository" + }, + "temp_clone_token": { + "type": "string", + "nullable": true + }, + "allow_squash_merge": { + "type": "boolean" + }, + "allow_auto_merge": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_update_branch": { + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean" + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "organization": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "parent": { + "$ref": "#/components/schemas/repository" + }, + "source": { + "$ref": "#/components/schemas/repository" + }, + "forks": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "anonymous_access_enabled": { + "default": true, + "type": "boolean" + }, + "code_of_conduct": { + "$ref": "#/components/schemas/code-of-conduct-simple" + }, + "security_and_analysis": { + "$ref": "#/components/schemas/security-and-analysis" + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "has_discussions", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at", + "network_count", + "subscribers_count" + ] + }, + "hook": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "$ref": "#/components/schemas/webhook-config" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "test_url": { + "type": "string", + "format": "uri" + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "last_response": { + "$ref": "#/components/schemas/hook-response" + } + }, + "required": [ + "id", + "url", + "type", + "name", + "active", + "events", + "config", + "ping_url", + "created_at", + "updated_at", + "last_response", + "test_url" + ] + }, + "hook-response": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + } + }, + "required": [ + "code", + "status", + "message" + ] + }, + "issue": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string", + "nullable": true, + "enum": [ + "completed", + "reopened", + "not_planned", + "duplicate" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "labels": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "nullable": true + }, + "default": { + "type": "boolean" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + } + } + ] + } + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "locked": { + "type": "boolean" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "comments": { + "type": "integer" + }, + "pull_request": { + "type": "object", + "properties": { + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "diff_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "patch_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "diff_url", + "html_url", + "patch_url", + "url" + ] + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "closed_by": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "repository": { + "$ref": "#/components/schemas/repository" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "parent_issue_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + } + }, + "required": [ + "assignee", + "closed_at", + "comments", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "labels", + "labels_url", + "milestone", + "number", + "repository_url", + "state", + "locked", + "title", + "url", + "user", + "created_at", + "updated_at" + ] + }, + "issue-comment": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + } + }, + "required": [ + "id", + "node_id", + "html_url", + "issue_url", + "user", + "url", + "created_at", + "updated_at" + ] + }, + "issue-dependencies-summary": { + "type": "object", + "properties": { + "blocked_by": { + "type": "integer" + }, + "blocking": { + "type": "integer" + }, + "total_blocked_by": { + "type": "integer" + }, + "total_blocking": { + "type": "integer" + } + }, + "required": [ + "blocked_by", + "blocking", + "total_blocked_by", + "total_blocking" + ] + }, + "issue-field-value": { + "type": "object", + "properties": { + "issue_field_id": { + "type": "integer", + "format": "int64" + }, + "issue_field_name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "data_type": { + "type": "string", + "enum": [ + "text", + "single_select", + "multi_select", + "number", + "date" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + } + ], + "nullable": true + }, + "single_select_option": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ], + "nullable": true + }, + "multi_select_options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ] + }, + "nullable": true + } + }, + "required": [ + "issue_field_id", + "node_id", + "data_type", + "value" + ] + }, + "issue-search-result-item": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + }, + "nullable": true + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "archived_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + } + } + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "text_matches": { + "$ref": "#/components/schemas/search-result-text-matches" + }, + "pull_request": { + "type": "object", + "properties": { + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "diff_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "patch_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "diff_url", + "html_url", + "patch_url", + "url" + ] + }, + "body": { + "type": "string" + }, + "score": { + "type": "number" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "draft": { + "type": "boolean" + }, + "repository": { + "$ref": "#/components/schemas/repository" + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + } + }, + "required": [ + "assignee", + "closed_at", + "comments", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "labels", + "labels_url", + "milestone", + "number", + "repository_url", + "state", + "locked", + "title", + "url", + "user", + "author_association", + "created_at", + "updated_at", + "score" + ] + }, + "issue-type": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "enum": [ + "gray", + "blue", + "green", + "yellow", + "orange", + "red", + "pink", + "purple" + ], + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "is_enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "description" + ] + }, + "label": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "archived_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "description", + "color", + "default", + "archived_at", + "archived_by" + ] + }, + "link": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + }, + "milestone": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "default": "open" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "creator": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "open_issues": { + "type": "integer" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "due_on": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "closed_issues", + "creator", + "description", + "due_on", + "closed_at", + "id", + "node_id", + "labels_url", + "html_url", + "number", + "open_issues", + "state", + "title", + "url", + "created_at", + "updated_at" + ] + }, + "minimal-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string" + }, + "homepage": { + "type": "string", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + } + }, + "role_name": { + "type": "string" + }, + "temp_clone_token": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "code_of_conduct": { + "$ref": "#/components/schemas/code-of-conduct" + }, + "license": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true + }, + "node_id": { + "type": "string" + } + }, + "nullable": true + }, + "forks": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "security_and_analysis": { + "$ref": "#/components/schemas/security-and-analysis" + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url" + ] + }, + "nullable-integration": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "owner": { + "oneOf": [ + { + "$ref": "#/components/schemas/simple-user" + }, + { + "$ref": "#/components/schemas/enterprise" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "external_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "type": "object", + "properties": { + "issues": { + "type": "string" + }, + "checks": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "contents": { + "type": "string" + }, + "deployments": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "installations_count": { + "type": "integer" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at", + "permissions", + "events" + ] + }, + "nullable-issue-comment": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + } + }, + "required": [ + "id", + "node_id", + "html_url", + "issue_url", + "user", + "url", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-issue-comment-minimized": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "reason" + ], + "nullable": true + }, + "nullable-license-simple": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "spdx_id": { + "type": "string", + "nullable": true + }, + "node_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "key", + "name", + "url", + "spdx_id", + "node_id" + ], + "nullable": true + }, + "nullable-milestone": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "default": "open" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "creator": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "open_issues": { + "type": "integer" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "due_on": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "closed_issues", + "creator", + "description", + "due_on", + "closed_at", + "id", + "node_id", + "labels_url", + "html_url", + "number", + "open_issues", + "state", + "title", + "url", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-pinned-issue-comment": { + "type": "object", + "properties": { + "pinned_at": { + "type": "string", + "format": "date-time" + }, + "pinned_by": { + "$ref": "#/components/schemas/nullable-simple-user" + } + }, + "required": [ + "pinned_at", + "pinned_by" + ], + "nullable": true + }, + "nullable-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean", + "deprecated": true + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + }, + "code_search_index_status": { + "type": "object", + "properties": { + "lexical_search_ok": { + "type": "boolean" + }, + "lexical_commit_sha": { + "type": "string" + } + } + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-simple-user": { + "type": "object", + "properties": { + "name": { + "nullable": true, + "type": "string" + }, + "email": { + "nullable": true, + "type": "string" + }, + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_at": { + "type": "string" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url" + ], + "nullable": true + }, + "org-hook": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "insecure_ssl": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "secret": { + "type": "string" + } + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "url", + "type", + "name", + "active", + "events", + "config", + "ping_url", + "created_at", + "updated_at" + ] + }, + "organization-full": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "format": "uri", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "is_verified": { + "type": "boolean" + }, + "has_organization_projects": { + "type": "boolean" + }, + "has_repository_projects": { + "type": "boolean" + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer", + "nullable": true + }, + "disk_usage": { + "type": "integer", + "nullable": true + }, + "collaborators": { + "type": "integer", + "nullable": true + }, + "billing_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "plan": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + }, + "filled_seats": { + "type": "integer" + }, + "seats": { + "type": "integer" + } + }, + "required": [ + "name", + "space", + "private_repos" + ] + }, + "default_repository_permission": { + "type": "string", + "nullable": true + }, + "default_repository_branch": { + "type": "string", + "nullable": true + }, + "members_can_create_repositories": { + "type": "boolean", + "nullable": true + }, + "two_factor_requirement_enabled": { + "type": "boolean", + "nullable": true + }, + "members_allowed_repository_creation_type": { + "type": "string" + }, + "members_can_create_public_repositories": { + "type": "boolean" + }, + "members_can_create_private_repositories": { + "type": "boolean" + }, + "members_can_create_internal_repositories": { + "type": "boolean" + }, + "members_can_create_pages": { + "type": "boolean" + }, + "members_can_create_public_pages": { + "type": "boolean" + }, + "members_can_create_private_pages": { + "type": "boolean" + }, + "members_can_delete_repositories": { + "type": "boolean" + }, + "members_can_change_repo_visibility": { + "type": "boolean" + }, + "members_can_invite_outside_collaborators": { + "type": "boolean" + }, + "members_can_delete_issues": { + "type": "boolean" + }, + "display_commenter_full_name_setting_enabled": { + "type": "boolean" + }, + "readers_can_create_discussions": { + "type": "boolean" + }, + "members_can_create_teams": { + "type": "boolean" + }, + "members_can_view_dependency_insights": { + "type": "boolean" + }, + "members_can_fork_private_repositories": { + "type": "boolean", + "nullable": true + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "advanced_security_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependabot_alerts_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependabot_security_updates_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependency_graph_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_push_protection_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_push_protection_custom_link_enabled": { + "type": "boolean" + }, + "secret_scanning_push_protection_custom_link": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "archived_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deploy_keys_enabled_for_repositories": { + "type": "boolean" + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description", + "html_url", + "has_organization_projects", + "has_repository_projects", + "public_repos", + "public_gists", + "followers", + "following", + "type", + "created_at", + "updated_at", + "archived_at" + ] + }, + "organization-simple": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description" + ] + }, + "organization-simple-webhooks": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description" + ] + }, + "private-user": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "user_view_type": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "name": { + "type": "string", + "nullable": true + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "notification_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "hireable": { + "type": "boolean", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "private_gists": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "disk_usage": { + "type": "integer" + }, + "collaborators": { + "type": "integer" + }, + "two_factor_authentication": { + "type": "boolean" + }, + "plan": { + "type": "object", + "properties": { + "collaborators": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + } + }, + "required": [ + "collaborators", + "name", + "space", + "private_repos" + ] + }, + "business_plus": { + "type": "boolean" + }, + "ldap_dn": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url", + "bio", + "blog", + "company", + "email", + "followers", + "following", + "hireable", + "location", + "name", + "public_gists", + "public_repos", + "created_at", + "updated_at", + "collaborators", + "disk_usage", + "owned_private_repos", + "private_gists", + "total_private_repos", + "two_factor_authentication" + ] + }, + "public-user": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "user_view_type": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "name": { + "type": "string", + "nullable": true + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "notification_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "hireable": { + "type": "boolean", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "plan": { + "type": "object", + "properties": { + "collaborators": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + } + }, + "required": [ + "collaborators", + "name", + "space", + "private_repos" + ] + }, + "private_gists": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "disk_usage": { + "type": "integer" + }, + "collaborators": { + "type": "integer" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url", + "bio", + "blog", + "company", + "email", + "followers", + "following", + "hireable", + "location", + "name", + "public_gists", + "public_repos", + "created_at", + "updated_at" + ], + "additionalProperties": false + }, + "pull-request": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "review_comment_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + }, + "body": { + "type": "string", + "nullable": true + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "description", + "color", + "default", + "archived_at", + "archived_by" + ] + } + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "requested_reviewers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "requested_teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/team-simple" + } + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "$ref": "#/components/schemas/repository" + }, + "sha": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "label", + "ref", + "repo", + "sha", + "user" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "$ref": "#/components/schemas/repository" + }, + "sha": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "label", + "ref", + "repo", + "sha", + "user" + ] + }, + "_links": { + "type": "object", + "properties": { + "comments": { + "$ref": "#/components/schemas/link" + }, + "commits": { + "$ref": "#/components/schemas/link" + }, + "statuses": { + "$ref": "#/components/schemas/link" + }, + "html": { + "$ref": "#/components/schemas/link" + }, + "issue": { + "$ref": "#/components/schemas/link" + }, + "review_comments": { + "$ref": "#/components/schemas/link" + }, + "review_comment": { + "$ref": "#/components/schemas/link" + }, + "self": { + "$ref": "#/components/schemas/link" + } + }, + "required": [ + "comments", + "commits", + "statuses", + "html", + "issue", + "review_comments", + "review_comment", + "self" + ] + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "auto_merge": { + "$ref": "#/components/schemas/auto-merge" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "draft": { + "type": "boolean" + }, + "merged": { + "type": "boolean" + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged_by": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "comments": { + "type": "integer" + }, + "review_comments": { + "type": "integer" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "commits": { + "type": "integer" + }, + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "changed_files": { + "type": "integer" + } + }, + "required": [ + "_links", + "assignee", + "labels", + "base", + "body", + "closed_at", + "comments_url", + "commits_url", + "created_at", + "diff_url", + "head", + "html_url", + "id", + "node_id", + "issue_url", + "merge_commit_sha", + "merged_at", + "milestone", + "number", + "patch_url", + "review_comment_url", + "review_comments_url", + "statuses_url", + "state", + "locked", + "title", + "updated_at", + "url", + "user", + "author_association", + "auto_merge", + "additions", + "changed_files", + "comments", + "commits", + "deletions", + "mergeable", + "mergeable_state", + "merged", + "maintainer_can_modify", + "merged_by", + "review_comments" + ] + }, + "pull-request-review": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "body": { + "type": "string" + }, + "state": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "pull_request_url": { + "type": "string", + "format": "uri" + }, + "_links": { + "type": "object", + "properties": { + "html": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "html", + "pull_request" + ] + }, + "submitted_at": { + "type": "string", + "format": "date-time" + }, + "commit_id": { + "type": "string", + "nullable": true + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + } + }, + "required": [ + "id", + "node_id", + "user", + "body", + "state", + "commit_id", + "html_url", + "pull_request_url", + "_links", + "author_association" + ] + }, + "pull-request-stack": { + "type": "object", + "properties": { + "base": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ] + }, + "size": { + "type": "integer" + }, + "position": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "number": { + "type": "integer" + } + }, + "required": [ + "base" + ], + "nullable": true + }, + "pull-request-webhook": { + "allOf": [ + { + "$ref": "#/components/schemas/pull-request" + }, + { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_update_branch": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + } + } + } + ] + }, + "reaction-rollup": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "total_count": { + "type": "integer" + }, + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "rocket": { + "type": "integer" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean", + "deprecated": true + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + }, + "code_search_index_status": { + "type": "object", + "properties": { + "lexical_search_ok": { + "type": "boolean" + }, + "lexical_commit_sha": { + "type": "string" + } + } + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ] + }, + "repository-webhooks": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "organization": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean" + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "template_repository": { + "nullable": true, + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "gravatar_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "followers_url": { + "type": "string" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string" + }, + "organizations_url": { + "type": "string" + }, + "repos_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + } + } + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string" + }, + "deployments_url": { + "type": "string" + }, + "downloads_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "forks_url": { + "type": "string" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string" + }, + "merges_url": { + "type": "string" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "tags_url": { + "type": "string" + }, + "teams_url": { + "type": "string" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string" + }, + "hooks_url": { + "type": "string" + }, + "svn_url": { + "type": "string" + }, + "homepage": { + "type": "string" + }, + "language": { + "type": "string" + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + } + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "type": "boolean" + }, + "allow_auto_merge": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "allow_update_branch": { + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean" + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + } + } + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ] + }, + "scim-error": { + "type": "object", + "properties": { + "message": { + "type": "string", + "nullable": true + }, + "documentation_url": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer" + }, + "scimType": { + "type": "string", + "nullable": true + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "search-result-text-matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "object_url": { + "type": "string" + }, + "object_type": { + "nullable": true, + "type": "string" + }, + "property": { + "type": "string" + }, + "fragment": { + "type": "string" + }, + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "indices": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + } + }, + "security-and-analysis": { + "nullable": true, + "type": "object", + "properties": { + "advanced_security": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "code_security": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "dependabot_security_updates": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_non_provider_patterns": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_ai_detection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "properties": { + "reviewers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer" + }, + "reviewer_type": { + "type": "string", + "enum": [ + "TEAM", + "ROLE" + ] + }, + "mode": { + "type": "string", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" + } + } + } + } + } + } + } + }, + "simple-commit-status": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "context": { + "type": "string" + }, + "target_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "required": { + "type": "boolean", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "node_id", + "state", + "context", + "target_url", + "avatar_url", + "url", + "created_at", + "updated_at" + ] + }, + "simple-installation": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "id", + "node_id" + ] + }, + "simple-user": { + "type": "object", + "properties": { + "name": { + "nullable": true, + "type": "string" + }, + "email": { + "nullable": true, + "type": "string" + }, + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_at": { + "type": "string" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url" + ] + }, + "sub-issues-summary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "percent_completed": { + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ] + }, + "team-simple": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "members_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string" + }, + "notification_setting": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "ldap_dn": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enterprise", + "organization" + ] + }, + "organization_id": { + "type": "integer" + }, + "enterprise_id": { + "type": "integer" + } + }, + "required": [ + "id", + "node_id", + "url", + "members_url", + "name", + "description", + "permission", + "html_url", + "repositories_url", + "slug", + "type" + ] + }, + "thread": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "repository": { + "$ref": "#/components/schemas/minimal-repository" + }, + "subject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "latest_comment_url": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "title", + "url", + "latest_comment_url", + "type" + ] + }, + "reason": { + "type": "string" + }, + "unread": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "last_read_at": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string" + }, + "subscription_url": { + "type": "string" + } + }, + "required": [ + "id", + "unread", + "reason", + "updated_at", + "last_read_at", + "subject", + "repository", + "url", + "subscription_url" + ] + }, + "validation-error": { + "type": "object", + "required": [ + "message", + "documentation_url" + ], + "properties": { + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "resource": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "index": { + "type": "integer" + }, + "value": { + "oneOf": [ + { + "type": "string", + "nullable": true + }, + { + "type": "integer", + "nullable": true + }, + { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + } + ] + } + } + } + } + } + }, + "webhook-config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + } + } + }, + "webhook-config-content-type": { + "type": "string" + }, + "webhook-config-insecure-ssl": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "webhook-config-secret": { + "type": "string" + }, + "webhook-config-url": { + "type": "string", + "format": "uri" + }, + "webhook-issue-comment-created": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "created" + ] + }, + "comment": { + "type": "object", + "properties": { + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "html_url", + "issue_url", + "id", + "node_id", + "user", + "created_at", + "updated_at", + "author_association", + "performed_via_github_app", + "body", + "reactions" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "issue": { + "allOf": [ + { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run", + "reminder", + "pull_request_review_thread" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "repository_url", + "labels_url", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "number", + "title", + "user", + "assignees", + "milestone", + "comments", + "created_at", + "updated_at", + "closed_at", + "author_association", + "active_lock_reason", + "body", + "reactions" + ] + }, + { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true + } + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string" + } + } + }, + "repository_url": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "timeline_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "followers_url": { + "type": "string" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string" + }, + "received_events_url": { + "type": "string" + }, + "repos_url": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + }, + "required": [ + "labels", + "state", + "locked", + "assignee" + ] + } + ] + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "issue", + "comment", + "repository", + "sender" + ] + }, + "webhook-issues-opened": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "opened" + ] + }, + "changes": { + "type": "object", + "properties": { + "old_issue": { + "type": "object", + "nullable": true, + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "type": { + "$ref": "#/components/schemas/issue-type" + } + }, + "required": [ + "id", + "number" + ] + }, + "old_repository": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + } + }, + "required": [ + "old_issue", + "old_repository" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "issue": { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run", + "security_and_analysis", + "pull_request_review_thread", + "reminder" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "repository_url", + "labels_url", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "number", + "title", + "user", + "assignees", + "milestone", + "comments", + "created_at", + "updated_at", + "closed_at", + "author_association", + "active_lock_reason", + "body", + "reactions" + ] + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "issue", + "repository", + "sender" + ] + }, + "webhook-ping": { + "type": "object", + "properties": { + "hook": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "app_id": { + "type": "integer" + }, + "config": { + "type": "object", + "properties": { + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "url": { + "$ref": "#/components/schemas/webhook-config-url" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "last_response": { + "$ref": "#/components/schemas/hook-response" + }, + "name": { + "type": "string", + "enum": [ + "web" + ] + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "test_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "type", + "name", + "active", + "events", + "config", + "created_at", + "updated_at" + ] + }, + "hook_id": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + }, + "zen": { + "type": "string" + } + } + }, + "webhook-ping-form-encoded": { + "type": "object", + "properties": { + "payload": { + "type": "string" + } + }, + "required": [ + "payload" + ] + }, + "webhook-pull-request-closed": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "closed" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "$ref": "#/components/schemas/webhooks_number" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "$ref": "#/components/schemas/pull-request-webhook" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + "webhook-pull-request-opened": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "opened" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "$ref": "#/components/schemas/webhooks_number" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "$ref": "#/components/schemas/pull-request-webhook" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + "webhook-pull-request-review-requested": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "review_requested" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "commits": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "html": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "issue": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comment": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "statuses": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "self", + "html", + "issue", + "comments", + "review_comments", + "review_comment", + "commits", + "statuses" + ] + }, + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "additions": { + "type": "integer" + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto_merge": { + "type": "object", + "nullable": true, + "properties": { + "commit_message": { + "type": "string", + "nullable": true + }, + "commit_title": { + "type": "string", + "nullable": true + }, + "enabled_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "commits": { + "type": "integer" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deletions": { + "type": "integer" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "draft": { + "type": "boolean" + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "locked": { + "type": "boolean" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "type": "boolean", + "nullable": true + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "merged_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "requested_reviewers": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + } + ] + } + }, + "requested_teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + }, + "review_comment_url": { + "type": "string", + "format": "uri-template" + }, + "review_comments": { + "type": "integer" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "id", + "node_id", + "html_url", + "diff_url", + "patch_url", + "issue_url", + "number", + "state", + "locked", + "title", + "user", + "body", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "merge_commit_sha", + "assignee", + "assignees", + "requested_reviewers", + "requested_teams", + "labels", + "milestone", + "commits_url", + "review_comments_url", + "review_comment_url", + "comments_url", + "statuses_url", + "head", + "base", + "_links", + "author_association", + "auto_merge", + "active_lock_reason", + "draft" + ] + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "requested_reviewer": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "requested_reviewer", + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "review_requested" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "commits": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "html": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "issue": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comment": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "statuses": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "self", + "html", + "issue", + "comments", + "review_comments", + "review_comment", + "commits", + "statuses" + ] + }, + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "additions": { + "type": "integer" + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto_merge": { + "type": "object", + "nullable": true, + "properties": { + "commit_message": { + "type": "string", + "nullable": true + }, + "commit_title": { + "type": "string", + "nullable": true + }, + "enabled_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "commits": { + "type": "integer" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deletions": { + "type": "integer" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "draft": { + "type": "boolean" + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "locked": { + "type": "boolean" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "type": "boolean", + "nullable": true + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "merged_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "requested_reviewers": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + ] + } + }, + "requested_teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + }, + "review_comment_url": { + "type": "string", + "format": "uri-template" + }, + "review_comments": { + "type": "integer" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "id", + "node_id", + "html_url", + "diff_url", + "patch_url", + "issue_url", + "number", + "state", + "locked", + "title", + "user", + "body", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "merge_commit_sha", + "assignee", + "assignees", + "requested_reviewers", + "requested_teams", + "labels", + "milestone", + "commits_url", + "review_comments_url", + "review_comment_url", + "comments_url", + "statuses_url", + "head", + "base", + "_links", + "author_association", + "auto_merge", + "active_lock_reason", + "draft" + ] + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "requested_team": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "requested_team", + "action", + "number", + "pull_request", + "repository", + "sender" + ] + } + ] + }, + "webhook-push": { + "type": "object", + "properties": { + "after": { + "type": "string" + }, + "base_ref": { + "$ref": "#/components/schemas/webhooks_nullable_string" + }, + "before": { + "type": "string" + }, + "commits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "committer": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "distinct": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "modified": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "tree_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "tree_id", + "distinct", + "message", + "timestamp", + "url", + "author", + "committer" + ] + } + }, + "compare": { + "type": "string" + }, + "created": { + "type": "boolean" + }, + "deleted": { + "type": "boolean" + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "forced": { + "type": "boolean" + }, + "head_commit": { + "type": "object", + "nullable": true, + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "committer": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "distinct": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "modified": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "tree_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "tree_id", + "distinct", + "message", + "timestamp", + "url", + "author", + "committer" + ] + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pusher": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "ref": { + "type": "string" + }, + "repository": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "ref", + "before", + "after", + "created", + "deleted", + "forced", + "base_ref", + "compare", + "commits", + "head_commit", + "repository", + "pusher" + ] + }, + "webhooks_nullable_string": { + "type": "string", + "nullable": true + }, + "webhooks_number": { + "type": "integer" + } + } + } +} diff --git a/e2e/integration/fake-github/contract/specs/schema.graphql b/e2e/integration/fake-github/contract/specs/schema.graphql new file mode 100644 index 000000000..263b2a77f --- /dev/null +++ b/e2e/integration/fake-github/contract/specs/schema.graphql @@ -0,0 +1,153 @@ +interface Actor { + login: String! +} + +enum CommentAuthorAssociation { + COLLABORATOR + CONTRIBUTOR + FIRST_TIMER + FIRST_TIME_CONTRIBUTOR + MANNEQUIN + MEMBER + NONE + OWNER +} + +scalar DateTime + +type Issue { + author: Actor + authorAssociation: CommentAuthorAssociation! + body: String! + createdAt: DateTime! + labels(after: String, before: String, first: Int, last: Int, orderBy: LabelOrder = {direction: ASC, field: CREATED_AT}): LabelConnection + milestone: Milestone + number: Int! + repository: Repository! + state: IssueState! + title: String! + updatedAt: DateTime! + url: URI! +} + +enum IssueState { + CLOSED + OPEN +} + +type Label { + color: String! + name: String! +} + +type LabelConnection { + nodes: [Label] +} + +input LabelOrder { + direction: OrderDirection! + field: LabelOrderField! +} + +enum LabelOrderField { + CREATED_AT + ISSUE_COUNT + NAME +} + +type Milestone { + title: String! +} + +enum OrderDirection { + ASC + DESC +} + +type Organization implements Actor & RepositoryOwner { + login: String! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! +} + +type PullRequest { + additions: Int! + author: Actor + authorAssociation: CommentAuthorAssociation! + body: String! + changedFiles: Int! + createdAt: DateTime! + deletions: Int! + labels(after: String, before: String, first: Int, last: Int, orderBy: LabelOrder = {direction: ASC, field: CREATED_AT}): LabelConnection + milestone: Milestone + number: Int! + repository: Repository! + reviewRequests(after: String, before: String, first: Int, last: Int): ReviewRequestConnection + state: PullRequestState! + title: String! + updatedAt: DateTime! + url: URI! +} + +enum PullRequestState { + CLOSED + MERGED + OPEN +} + +type Query { + search(after: String, before: String, first: Int, last: Int, query: String!, type: SearchType!): SearchResultItemConnection! +} + +type Repository { + name: String! + nameWithOwner: String! + owner: RepositoryOwner! + url: URI! +} + +interface RepositoryOwner { + login: String! +} + +union RequestedReviewer = Team | User + +type ReviewRequest { + requestedReviewer: RequestedReviewer +} + +type ReviewRequestConnection { + nodes: [ReviewRequest] +} + +union SearchResultItem = Issue | Organization | PullRequest | Repository | User + +type SearchResultItemConnection { + issueCount: Int! + nodes: [SearchResultItem] + pageInfo: PageInfo! +} + +enum SearchType { + DISCUSSION + ISSUE + ISSUE_ADVANCED + ISSUE_HYBRID + ISSUE_SEMANTIC + REPOSITORY + USER +} + +type Team { + organization: Organization! + slug: String! +} + +scalar URI + +type User implements Actor & RepositoryOwner { + login: String! +} diff --git a/e2e/integration/fake-github/contract/update_specs.mjs b/e2e/integration/fake-github/contract/update_specs.mjs new file mode 100644 index 000000000..5cba3dcc3 --- /dev/null +++ b/e2e/integration/fake-github/contract/update_specs.mjs @@ -0,0 +1,193 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Trims GitHub's published REST description (OpenAPI, with webhook payloads) and GraphQL schema +// down to what fake-github serves. +// node update_specs.mjs rewrite specs/ from upstream +// node update_specs.mjs --check exit 1 if upstream drifted from the committed copies + +/* eslint-disable no-console */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {Kind, assertValidSchema, buildASTSchema, lexicographicSortSchema, parse, printSchema, specifiedScalarTypes} from 'graphql'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const specsDir = path.join(dir, 'specs'); +const check = process.argv.includes('--check'); +const manifest = JSON.parse(await fs.readFile(path.join(dir, 'operations.json'), 'utf8')); + +const outputs = { + 'rest.json': `${JSON.stringify(trimRest(await download(manifest.rest.url, 'json'), manifest.rest), null, 2)}\n`, + 'schema.graphql': trimGraphQL(await download(manifest.graphql.url, 'text'), manifest.graphql.fields), +}; + +let drifted = false; +for (const [file, next] of Object.entries(outputs)) { + const outPath = path.join(specsDir, file); + if (check) { + if (await fs.readFile(outPath, 'utf8').catch(() => '') !== next) { + drifted = true; + console.error(`${file}: GitHub changed what fake-github serves`); + } + continue; + } + await fs.mkdir(specsDir, {recursive: true}); + await fs.writeFile(outPath, next); + console.log(`${file}: updated`); +} + +if (drifted) { + console.error('Run `npm run contract:update`, review the diff, and update fake-github to match.'); + process.exit(1); +} + +async function download(url, as) { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download ${url}: ${res.status}`); + } + return as === 'json' ? res.json() : res.text(); +} + +function trimRest(spec, {operations, webhooks}) { + const paths = {}; + for (const operation of operations) { + const [method, opPath] = operation.split(' '); + const op = spec.paths[opPath]?.[method.toLowerCase()]; + if (!op) { + throw new Error(`rest: ${operation} is not in the upstream description`); + } + paths[opPath] = {...paths[opPath], [method.toLowerCase()]: stripDocs({requestBody: op.requestBody, responses: op.responses})}; + } + + const hooks = {}; + for (const name of webhooks) { + const op = spec['x-webhooks'][name]?.post; + if (!op) { + throw new Error(`rest: webhook ${name} is not in the upstream description`); + } + hooks[name] = {post: stripDocs({requestBody: op.requestBody})}; + } + + const components = {}; + const queue = collectRefs([paths, hooks]); + while (queue.length) { + const [kind, name] = queue.pop().replace('#/components/', '').split('/'); + if (components[kind]?.[name]) { + continue; + } + components[kind] = {...components[kind], [name]: stripDocs(spec.components[kind][name])}; + queue.push(...collectRefs(components[kind][name])); + } + + return { + openapi: spec.openapi, + info: {title: spec.info.title}, + paths, + 'x-webhooks': hooks, + components: Object.fromEntries(Object.keys(components).sort().map((kind) => [kind, sortKeys(components[kind])])), + }; +} + +// GitHub edits prose and examples often; dropping them keeps --check focused on shape changes. +function stripDocs(node, isPropertyMap = false) { + if (Array.isArray(node)) { + return node.map((item) => stripDocs(item)); + } + if (!node || typeof node !== 'object') { + return node; + } + const out = {}; + for (const [key, value] of Object.entries(node)) { + if (!isPropertyMap && (['description', 'example', 'examples', 'summary', 'externalDocs', 'title'].includes(key) || key.startsWith('x-'))) { + continue; + } + out[key] = stripDocs(value, !isPropertyMap && key === 'properties'); + } + return out; +} + +function collectRefs(node, found = []) { + if (Array.isArray(node)) { + node.forEach((item) => collectRefs(item, found)); + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') { + found.push(value); + } else { + collectRefs(value, found); + } + } + } + return found; +} + +function sortKeys(object) { + return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b))); +} + +// Keeps the listed object/interface fields, every enum, scalar and input type they reference, and unions +// narrowed to listed members. Querying anything else then fails validation, like an uncovered REST route. +function trimGraphQL(sdl, fields) { + const definitions = new Map(parse(sdl).definitions.filter((d) => 'name' in d && d.name).map((d) => [d.name.value, d])); + const builtins = new Set(specifiedScalarTypes.map((t) => t.name)); + const kept = new Map(); + const queue = ['Query']; + + while (queue.length) { + const name = queue.pop(); + if (kept.has(name) || builtins.has(name)) { + continue; + } + const def = definitions.get(name); + if (!def) { + throw new Error(`graphql: ${name} is not in the upstream schema`); + } + + let trimmed = stripDirectives({...def, description: undefined}); + if (def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.INTERFACE_TYPE_DEFINITION) { + const wanted = fields[name]; + if (!wanted) { + throw new Error(`graphql: ${name} is reachable from a listed field; add it to operations.json`); + } + const fieldDefs = def.fields.filter((f) => wanted.includes(f.name.value)); + const missing = wanted.filter((f) => !fieldDefs.some((d) => d.name.value === f)); + if (missing.length) { + throw new Error(`graphql: ${name} has no field(s) ${missing.join(', ')}`); + } + trimmed = { + ...trimmed, + interfaces: (def.interfaces ?? []).filter((i) => fields[i.name.value]), + fields: fieldDefs.map((f) => stripDirectives({...f, description: undefined, arguments: f.arguments?.map((a) => stripDirectives({...a, description: undefined}))})), + }; + for (const f of trimmed.fields) { + queue.push(namedType(f.type), ...(f.arguments ?? []).map((a) => namedType(a.type))); + } + queue.push(...trimmed.interfaces.map((i) => i.name.value)); + } else if (def.kind === Kind.UNION_TYPE_DEFINITION) { + trimmed = {...trimmed, types: def.types.filter((t) => fields[t.name.value])}; + queue.push(...trimmed.types.map((t) => t.name.value)); + } else if (def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + trimmed = {...trimmed, fields: def.fields.map((f) => stripDirectives({...f, description: undefined}))}; + queue.push(...trimmed.fields.map((f) => namedType(f.type))); + } else if (def.kind === Kind.ENUM_TYPE_DEFINITION) { + trimmed = {...trimmed, values: def.values.map((v) => stripDirectives({...v, description: undefined}))}; + } + kept.set(name, trimmed); + } + + const schema = buildASTSchema({kind: Kind.DOCUMENT, definitions: [...kept.values()]}); + assertValidSchema(schema); + return `${printSchema(lexicographicSortSchema(schema))}\n`; +} + +function namedType(type) { + return type.kind === Kind.NAMED_TYPE ? type.name.value : namedType(type.type); +} + +function stripDirectives(node) { + return {...node, directives: (node.directives ?? []).filter((d) => d.name.value === 'deprecated')}; +} diff --git a/e2e/integration/fake-github/contract/validator.ts b/e2e/integration/fake-github/contract/validator.ts new file mode 100644 index 000000000..b5f0b3ca1 --- /dev/null +++ b/e2e/integration/fake-github/contract/validator.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'node:fs'; +import path from 'node:path'; + +import Ajv, {type ErrorObject, type ValidateFunction} from 'ajv'; +import addFormats from 'ajv-formats'; +import {type DocumentNode, type GraphQLSchema, buildSchema, parse, validate} from 'graphql'; + +type JsonObject = Record; + +const specsDir = path.join(__dirname, 'specs'); +const rest: JsonObject = JSON.parse(fs.readFileSync(path.join(specsDir, 'rest.json'), 'utf8')); + +export const graphQLSchema: GraphQLSchema = buildSchema(fs.readFileSync(path.join(specsDir, 'schema.graphql'), 'utf8')); + +// GitHub answers any authenticated route with these, documented or not. +const basicError = { + type: 'object', + required: ['message'], + properties: {message: {type: 'string'}, documentation_url: {type: 'string'}, status: {type: 'string'}}, +}; +const apiWideResponses: Record = {401: basicError, 403: basicError, 404: basicError}; + +const ajv = new Ajv({strict: false, allErrors: true, validateFormats: true}); +addFormats(ajv); +for (const format of ['uri-template', 'repo.nwo', 'ssh-key', 'ssh-key fingerprint']) { + ajv.addFormat(format, true); +} +ajv.addSchema({components: fromOpenAPI(rest.components)}, 'rest'); + +type Operation = {method: string; template: string; pattern: RegExp; request?: ValidateFunction; responses: Map}; + +const operations: Operation[] = Object.entries(rest.paths as JsonObject).flatMap(([template, methods]) => Object.entries(methods as JsonObject).map(([method, op]) => ({ + method: method.toUpperCase(), + template, + pattern: templateToRegExp(template), + request: jsonSchema(op.requestBody) && ajv.compile(fromOpenAPI(jsonSchema(op.requestBody))), + responses: new Map(Object.entries(op.responses as JsonObject).flatMap(([status, response]) => { + const schema = jsonSchema(resolve(response)); + return schema ? [[status, ajv.compile(fromOpenAPI(schema))]] : []; + })), +}))); + +const webhooks = new Map(Object.entries(rest['x-webhooks'] as JsonObject).map(([name, hook]) => [name, ajv.compile(fromOpenAPI(jsonSchema(hook.post.requestBody)))])); +const apiWide = new Map(Object.entries(apiWideResponses).map(([status, schema]) => [status, ajv.compile(schema)])); + +export function findOperation(method: string, pathname: string): Operation | undefined { + return operations.find((op) => op.method === method && op.pattern.test(pathname)); +} + +export function validateRequest(method: string, pathname: string, body: unknown): string[] { + const op = findOperation(method, pathname); + if (!op) { + return [`${method} ${pathname} is not in operations.json`]; + } + return op.request ? describe(op.request, body, `${method} ${op.template} request`) : []; +} + +export function validateResponse(method: string, pathname: string, status: number, body: unknown): string[] { + const op = findOperation(method, pathname); + if (!op) { + return [`${method} ${pathname} is not in operations.json`]; + } + const validator = op.responses.get(String(status)) ?? apiWide.get(String(status)); + if (!validator) { + if (op.responses.has(String(status)) || status === 204 || status === 304) { + return []; + } + return [`${method} ${op.template}: GitHub does not document a ${status} response`]; + } + return describe(validator, body, `${method} ${op.template} ${status} response`); +} + +// GitHub names webhook payloads after the X-GitHub-Event header plus the action, e.g. pull-request-opened. +export function webhookSchemaName(event: string, action?: string): string | undefined { + const base = event.replaceAll('_', '-'); + return [action && `${base}-${action.replaceAll('_', '-')}`, base].find((name) => name && webhooks.has(name)) || undefined; +} + +export function validateWebhook(event: string, payload: JsonObject): string[] { + const name = webhookSchemaName(event, payload.action); + if (!name) { + return [`webhook ${event}${payload.action ? `.${payload.action}` : ''} is not in operations.json`]; + } + return describe(webhooks.get(name)!, payload, `webhook ${name}`); +} + +export function validateGraphQLQuery(query: string): {document?: DocumentNode; errors: string[]} { + let document: DocumentNode; + try { + document = parse(query); + } catch (err) { + return {errors: [(err as Error).message]}; + } + return {document, errors: validate(graphQLSchema, document).map((e) => e.message).filter((m) => !githubAcceptsShapeConflict.test(m))}; +} + +// graphql-js rejects `... on Issue { state } ... on PullRequest { state }` (IssueState vs PullRequestState); +// GitHub's graphql-ruby server answers it, and the plugin's sidebar query depends on that. +const githubAcceptsShapeConflict = /^Fields "state" conflict because they return conflicting types "(IssueState|PullRequestState)!?" and "(IssueState|PullRequestState)!?"\./; + +export const skeletonTimestamp = '2026-01-01T00:00:00Z'; + +// A minimal value for a component schema with every required field filled in. Callers overlay real +// state on top, so GitHub adding a required field changes the fixture instead of breaking it. +export function skeleton(schemaName: string): any { + const schema = rest.components.schemas[schemaName]; + if (!schema) { + throw new Error(`component schema ${schemaName} is not in the trimmed spec; reference it from an operation in operations.json`); + } + return fill(schema, 0); +} + +export function webhookSkeleton(name: string): any { + return fill(jsonSchema(rest['x-webhooks'][name].post.requestBody)!, 0); +} + +function fill(node: JsonObject, depth: number): any { + const schema = resolve(node) ?? {}; + if (schema.nullable || depth > 15) { + return null; + } + if (schema.allOf) { + return schema.allOf.reduce((acc: any, part: JsonObject) => merge(acc, fill(part, depth + 1)), {}); + } + const branches = schema.anyOf ?? schema.oneOf; + if (branches) { + return fill(branches.find((b: JsonObject) => resolve(b)?.type !== 'null') ?? branches[0], depth + 1); + } + if (schema.enum) { + return schema.enum[0]; + } + if (schema.default !== undefined && schema.default !== null) { + return schema.default; + } + switch (schema.type ?? (schema.properties ? 'object' : undefined)) { + case 'object': + return Object.fromEntries((schema.required ?? []).map((key: string) => [key, fill(schema.properties?.[key] ?? {}, depth + 1)])); + case 'array': + return Array.from({length: schema.minItems ?? 0}, () => fill(schema.items ?? {}, depth + 1)); + case 'integer': + case 'number': + return schema.minimum ?? 0; + case 'boolean': + return false; + case 'string': + return ({'date-time': skeletonTimestamp, date: skeletonTimestamp.slice(0, 10), uri: 'https://github.com/', email: 'e2e@example.com'} as Record)[schema.format] ?? ''; + default: + return null; + } +} + +export function merge(base: any, overlay: any): any { + if (!isPlainObject(base) || !isPlainObject(overlay)) { + return overlay === undefined ? base : overlay; + } + const out = {...base}; + for (const [key, value] of Object.entries(overlay)) { + out[key] = merge(base[key], value); + } + return out; +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function describe(validator: ValidateFunction, value: unknown, label: string): string[] { + if (validator(value)) { + return []; + } + return (validator.errors ?? []).slice(0, 10).map((e: ErrorObject) => `${label}: ${e.instancePath || '/'} ${e.message}${e.params ? ` ${JSON.stringify(e.params)}` : ''}`); +} + +function resolve(node: JsonObject): JsonObject { + if (!node?.$ref) { + return node; + } + const [kind, name] = node.$ref.replace('#/components/', '').split('/'); + return resolve(rest.components[kind][name]); +} + +function jsonSchema(body: JsonObject | undefined): JsonObject | undefined { + const content = resolve(body as JsonObject)?.content; + return content?.['application/json']?.schema ?? content?.['application/scim+json']?.schema; +} + +// OpenAPI 3.0 marks optional nulls with `nullable`; ajv only understands JSON Schema's `type: [.., 'null']`. +function fromOpenAPI(node: any): any { + if (Array.isArray(node)) { + return node.map(fromOpenAPI); + } + if (!node || typeof node !== 'object') { + return node; + } + const out: JsonObject = {}; + for (const [key, value] of Object.entries(node)) { + if (key === '$ref') { + out.$ref = (value as string).replace(/^#\/components\//, 'rest#/components/'); + } else if (key !== 'nullable') { + out[key] = key === 'properties' || key === 'patternProperties' ? + Object.fromEntries(Object.entries(value as JsonObject).map(([k, v]) => [k, fromOpenAPI(v)])) : + fromOpenAPI(value); + } + } + if (node.nullable !== true) { + return out; + } + if (typeof out.type === 'string' && !out.allOf && !out.anyOf && !out.oneOf) { + return {...out, type: [out.type, 'null'], ...(out.enum ? {enum: [...out.enum, null]} : {})}; + } + return {anyOf: [out, {type: 'null'}]}; +} + +function templateToRegExp(template: string): RegExp { + const escaped = template.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\{[^}]+\}/g, '[^/]+'); + return new RegExp(`^${escaped}$`); +} diff --git a/e2e/integration/fake-github/server.ts b/e2e/integration/fake-github/server.ts new file mode 100644 index 000000000..b5227ae07 --- /dev/null +++ b/e2e/integration/fake-github/server.ts @@ -0,0 +1,821 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import crypto from 'node:crypto'; +import {type IncomingMessage, type Server, type ServerResponse, createServer} from 'node:http'; + +import {GraphQLError, execute} from 'graphql'; + +import {graphQLSchema, validateGraphQLQuery, validateRequest, validateResponse, validateWebhook} from './contract/validator'; +import {type AuthCode, type Hook, type Item, type Org, type Repo, State, type Token, WEB, fullName, hasScope, normalizeScopes, setAvatarBase, sha} from './state'; +import {type Delivery, type WebhookRequest, buildPayload, deliver, pingPayload} from './webhooks'; + +export type OAuthApp = {clientId: string; clientSecret: string; callbackURL: string; name: string; codeTTLms?: number}; +export type JournalEntry = {method: string; path: string; query: Record; status: number; body?: unknown}; +type Fault = {method: string; path: string; pattern: RegExp; status: number; body?: unknown; headers?: Record; times: number}; + +type Ctx = { + req: IncomingMessage; res: ServerResponse; url: URL; params: string[]; body: any; + token: Token | null; send: (status: number, body?: unknown, headers?: Record) => void; +}; +type Handler = (ctx: Ctx) => void | Promise; + +const docs = 'https://docs.github.com/rest'; +const ssoMessage = 'Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.'; + +export type FakeGitHub = {state: State; close: () => Promise}; + +export async function startFakeGitHub({port, publicURL, app}: {port: number; publicURL: string; app: OAuthApp}): Promise { + let state = new State(); + let oauthApp = app; + let journal: JournalEntry[] = []; + let violations: string[] = []; + let faults: Fault[] = []; + let pending: Promise[] = []; + let rateLimit = new Map(); + setAvatarBase(`${publicURL}/avatars`); + + const fail = (ctx: Ctx, status: number, message: string, headers?: Record) => ctx.send(status, {message, documentation_url: docs, status: String(status)}, headers); + const notFound = (ctx: Ctx) => fail(ctx, 404, 'Not Found'); + + const requireToken = (ctx: Ctx): Token | null => { + if (!ctx.token) { + fail(ctx, 401, ctx.req.headers.authorization ? 'Bad credentials' : 'Requires authentication'); + return null; + } + return ctx.token; + }; + + const ssoBlocked = (ctx: Ctx, owner: string): boolean => { + const org = state.orgs.get(owner.toLowerCase()); + if (!org?.ssoEnforced || !ctx.token || !org.members.has(ctx.token.login)) { + return false; + } + fail(ctx, 403, ssoMessage, {'X-GitHub-SSO': `required; url=${WEB}/orgs/${org.login}/sso?authorization_request=${crypto.randomBytes(12).toString('hex')}`}); + return true; + }; + + const visibleRepo = (ctx: Ctx, owner: string, name: string): Repo | null => { + const repo = state.repo(owner, name); + if (ssoBlocked(ctx, owner)) { + return null; + } + if (!repo || !state.canSee(ctx.token, repo)) { + notFound(ctx); + return null; + } + return repo; + }; + + const paginate = (ctx: Ctx, all: unknown[], {defaultSize = 30, maxSize = 100} = {}) => { + const perPage = Math.min(Number(ctx.url.searchParams.get('per_page') ?? defaultSize) || defaultSize, maxSize); + const page = Math.max(Number(ctx.url.searchParams.get('page') ?? 1) || 1, 1); + const last = Math.max(Math.ceil(all.length / perPage), 1); + const link = (p: number, rel: string) => { + const next = new URL(`${publicURL}${ctx.url.pathname}${ctx.url.search}`); + next.searchParams.set('page', String(p)); + return `<${next}>; rel="${rel}"`; + }; + const links = [page < last && link(page + 1, 'next'), page < last && link(last, 'last'), page > 1 && link(1, 'first'), page > 1 && link(page - 1, 'prev')].filter(Boolean); + const headers: Record = links.length ? {Link: links.join(', ')} : {}; + return {items: all.slice((page - 1) * perPage, page * perPage), headers}; + }; + + const sendPage = (ctx: Ctx, all: unknown[], sizes?: {defaultSize?: number; maxSize?: number}) => { + const {items, headers} = paginate(ctx, all, sizes); + ctx.send(200, items, headers); + }; + + const track = (delivery: Promise) => pending.push(delivery.catch((err) => violations.push(`fake-github failed to deliver a webhook: ${err}`))); + + const hooksFor = (repo: Repo): Hook[] => state.hooks.filter((h) => h.owner.toLowerCase() === repo.owner.toLowerCase() && (!h.repo || h.repo.toLowerCase() === repo.name.toLowerCase())); + + const emit = (repo: Repo, request: WebhookRequest) => { + const hooks = hooksFor(repo); + if (!hooks.length) { + return; + } + const payload = buildPayload(state, request); + violations.push(...validateWebhook(request.event, payload)); + track(deliver(hooks, request.event, payload)); + }; + + const routes: [string, RegExp, Handler][] = []; + const route = (method: string, pattern: string, handler: Handler) => { + routes.push([method, new RegExp(`^${pattern.replace(/:\w+/g, '([^/]+)')}$`), handler]); + }; + + route('GET', '/login/oauth/authorize', (ctx) => { + const q = ctx.url.searchParams; + if (q.get('client_id') !== oauthApp.clientId) { + return ctx.send(404, htmlPage('404', '

Not Found

')); + } + const state_ = q.get('state') ?? ''; + const redirectURI = q.get('redirect_uri') || oauthApp.callbackURL; + if (!redirectMatches(redirectURI, oauthApp.callbackURL)) { + return redirect(ctx, oauthApp.callbackURL, {error: 'redirect_uri_mismatch', error_description: 'The redirect_uri MUST match the registered callback URL for this application.', error_uri: 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-authorization-request-errors/#redirect-uri-mismatch', state: state_}); + } + if (!state.signedIn) { + return ctx.send(200, htmlPage('Sign in to GitHub', `
`)); + } + const scopes = parseScopeParam(q.get('scope')); + const previous = state.grants.get(`${oauthApp.clientId}:${state.signedIn.toLowerCase()}`); + if (previous && scopes.every((s) => hasScope(previous, s))) { + return issueCode(ctx, redirectURI, scopes, state_); + } + return ctx.send(200, htmlPage(`Authorize ${oauthApp.name}`, `

Authorize ${escape(oauthApp.name)}

Signed in as ${escape(state.signedIn)}

    ${scopes.map((s) => `
  • ${escape(s)}
  • `).join('')}
${hidden({client_id: oauthApp.clientId, redirect_uri: redirectURI, scope: scopes.join(' '), state: state_})}
`)); + }); + + route('POST', '/login/oauth/authorize', (ctx) => { + const {client_id: clientId, redirect_uri: redirectURI, scope, state: state_, authorize} = ctx.body; + if (clientId !== oauthApp.clientId || !state.signedIn || !redirectURI || !redirectMatches(redirectURI, oauthApp.callbackURL)) { + return ctx.send(422, htmlPage('Unprocessable', '

Unprocessable request

')); + } + if (authorize !== '1') { + return redirect(ctx, redirectURI, {error: 'access_denied', error_description: 'The user has denied your application access.', error_uri: 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-authorization-request-errors/#access-denied', state: state_}); + } + return issueCode(ctx, redirectURI, parseScopeParam(scope), state_); + }); + + route('POST', '/session', (ctx) => { + state.signedIn = state.login(String(ctx.body.login)).login; + ctx.send(302, '', {Location: String(ctx.body.return_to || '/')}); + }); + + // GitHub answers token-exchange failures with 200 and an error body. + route('POST', '/login/oauth/access_token', (ctx) => { + const basic = /^Basic (.+)$/.exec(ctx.req.headers.authorization ?? '')?.[1]; + const [headerId, headerSecret] = basic ? Buffer.from(basic, 'base64').toString().split(':').map(decodeURIComponent) : []; + const clientId = ctx.body.client_id ?? headerId; + const clientSecret = ctx.body.client_secret ?? headerSecret; + const json = (ctx.req.headers.accept ?? '').includes('application/json'); + const reply = (fields: Record) => (json ? ctx.send(200, fields) : ctx.send(200, new URLSearchParams(fields).toString(), {'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'})); + const oauthError = (error: string, description: string) => reply({error, error_description: description, error_uri: `https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#${error.replaceAll('_', '-')}`}); + + if (clientId !== oauthApp.clientId || clientSecret !== oauthApp.clientSecret) { + return oauthError('incorrect_client_credentials', 'The client_id and/or client_secret passed are incorrect.'); + } + const code = state.codes.get(String(ctx.body.code)); + if (!code || code.used || code.expiresAt <= Date.now() || code.clientId !== clientId) { + return oauthError('bad_verification_code', 'The code passed is incorrect or expired.'); + } + if (ctx.body.redirect_uri && ctx.body.redirect_uri !== code.redirectUri) { + return oauthError('redirect_uri_mismatch', 'The redirect_uri MUST match the registered callback URL for this application.'); + } + code.used = true; + const token = state.issueToken(code.login, clientId, state.grant(code.login, clientId, code.scopes)); + return reply({access_token: token.value, token_type: 'bearer', scope: token.scopes.join(',')}); + }); + + route('GET', '/api/user', (ctx) => { + const token = requireToken(ctx); + if (token) { + ctx.send(200, state.userJSON(state.login(token.login), 'private-user')); + } + }); + + route('GET', '/api/users/:username', (ctx) => { + const account = state.account(ctx.params[0]); + return account ? ctx.send(200, state.userJSON(account, 'public-user')) : notFound(ctx); + }); + + route('GET', '/api/user/orgs', (ctx) => { + const token = requireToken(ctx); + if (!token) { + return; + } + if (!hasScope(token.scopes, 'read:org', 'user')) { + return fail(ctx, 403, 'You need at least read:org scope or user scope to list your organizations.'); + } + sendPage(ctx, [...state.orgs.values()].filter((o) => o.members.has(token.login)).map((o) => state.orgJSON(o))); + }); + + route('GET', '/api/user/repos', (ctx) => { + const token = requireToken(ctx); + if (!token) { + return; + } + const affiliation = (ctx.url.searchParams.get('affiliation') ?? 'owner,collaborator,organization_member').split(','); + const repos = [...state.repos.values()].filter((r) => state.canSee(token, r) && ( + (affiliation.includes('owner') && r.owner === token.login) || + (affiliation.includes('collaborator') && r.writers.has(token.login) && r.owner !== token.login) || + (affiliation.includes('organization_member') && Boolean(state.orgs.get(r.owner.toLowerCase())?.members.has(token.login))))); + sendPage(ctx, sortRepos(repos).map((r) => state.repoJSON(r, token))); + }); + + route('GET', '/api/users/:username/repos', (ctx) => { + const account = state.account(ctx.params[0]); + if (!account) { + return notFound(ctx); + } + sendPage(ctx, sortRepos([...state.repos.values()].filter((r) => r.owner === account.login && !r.private)).map((r) => state.repoJSON(r, ctx.token, 'minimal-repository'))); + }); + + route('GET', '/api/orgs/:org', (ctx) => { + const org = state.orgs.get(ctx.params[0].toLowerCase()); + if (!org) { + return notFound(ctx); + } + if (!ssoBlocked(ctx, org.login)) { + ctx.send(200, state.orgJSON(org, 'organization-full')); + } + }); + + route('GET', '/api/orgs/:org/repos', (ctx) => { + const org = state.orgs.get(ctx.params[0].toLowerCase()); + if (!org) { + return notFound(ctx); + } + if (!ssoBlocked(ctx, org.login)) { + sendPage(ctx, sortRepos([...state.repos.values()].filter((r) => r.owner === org.login && state.canSee(ctx.token, r))).map((r) => state.repoJSON(r, ctx.token, 'minimal-repository'))); + } + }); + + // GitHub redirects non-members to the public membership check; members get a direct answer. + route('GET', '/api/orgs/:org/members/:username', (ctx) => { + const org = state.orgs.get(ctx.params[0].toLowerCase()); + if (!org) { + return notFound(ctx); + } + if (!ctx.token || !org.members.has(ctx.token.login)) { + return ctx.send(302, '', {Location: `${publicURL}/api/orgs/${org.login}/public_members/${ctx.params[1]}`}); + } + return org.members.has(state.account(ctx.params[1])?.login ?? '') ? ctx.send(204) : ctx.send(404, {message: 'User does not exist or is not a member of the organization', documentation_url: docs, status: '404'}); + }); + + route('GET', '/api/orgs/:org/public_members/:username', (ctx) => notFound(ctx)); + + route('GET', '/api/orgs/:org/hooks', (ctx) => { + const org = orgHookAccess(ctx); + if (org) { + sendPage(ctx, state.hooks.filter((h) => !h.repo && h.owner === org.login).map((h) => state.hookJSON(h, 'org-hook'))); + } + }); + + route('POST', '/api/orgs/:org/hooks', (ctx) => { + const org = orgHookAccess(ctx); + if (org) { + createHook(ctx, org.login, undefined); + } + }); + + route('GET', '/api/repos/:owner/:repo', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (repo) { + ctx.send(200, state.repoJSON(repo, ctx.token, 'full-repository')); + } + }); + + route('GET', '/api/repos/:owner/:repo/hooks', (ctx) => { + const repo = repoHookAccess(ctx, 'read:repo_hook'); + if (repo) { + sendPage(ctx, state.hooks.filter((h) => h.repo === repo.name && h.owner === repo.owner).map((h) => state.hookJSON(h, 'hook'))); + } + }); + + route('POST', '/api/repos/:owner/:repo/hooks', (ctx) => { + const repo = repoHookAccess(ctx, 'write:repo_hook'); + if (repo) { + createHook(ctx, repo.owner, repo.name); + } + }); + + route('GET', '/api/repos/:owner/:repo/issues/:number', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + const item = repo && state.item(repo.owner, repo.name, Number(ctx.params[2])); + if (repo) { + return item ? ctx.send(200, state.issueJSON(item)) : notFound(ctx); + } + }); + + route('POST', '/api/repos/:owner/:repo/issues', (ctx) => { + const token = requireToken(ctx); + const repo = token && visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (!token || !repo) { + return; + } + if (!hasScope(token.scopes, repo.private ? 'repo' : 'public_repo')) { + return notFound(ctx); + } + if (!ctx.body?.title) { + return ctx.send(422, {message: 'Validation Failed', errors: [{resource: 'Issue', code: 'missing_field', field: 'title'}], documentation_url: docs, status: '422'}); + } + const canPush = repo.writers.has(token.login); + const item = state.addItem(repo, { + kind: 'issue', title: ctx.body.title, body: ctx.body.body ?? '', author: token.login, + ...(canPush ? { + labels: (ctx.body.labels ?? []).map((l: string | {name: string}) => (typeof l === 'string' ? l : l.name)), + assignees: [...(ctx.body.assignees ?? []), ...(ctx.body.assignee ? [ctx.body.assignee] : [])].filter((a: string) => repo.writers.has(a)), + milestone: ctx.body.milestone ?? null, + } : {}), + }); + ctx.send(201, state.issueJSON(item)); + emit(repo, {event: 'issues', action: 'opened', repo: fullName(repo), number: item.number, sender: token.login}); + }); + + route('POST', '/api/repos/:owner/:repo/issues/:number/comments', (ctx) => { + const token = requireToken(ctx); + const repo = token && visibleRepo(ctx, ctx.params[0], ctx.params[1]); + const item = repo && state.item(repo.owner, repo.name, Number(ctx.params[2])); + if (!token || !repo) { + return; + } + if (!item || !hasScope(token.scopes, repo.private ? 'repo' : 'public_repo')) { + return notFound(ctx); + } + const comment = {id: state.id(), author: token.login, body: String(ctx.body?.body ?? ''), createdAt: state.now()}; + if (!comment.body) { + return ctx.send(422, {message: 'Validation Failed', errors: [{resource: 'IssueComment', code: 'missing_field', field: 'body'}], documentation_url: docs, status: '422'}); + } + item.comments.push(comment); + item.updatedAt = comment.createdAt; + ctx.send(201, state.commentJSON(item, comment)); + emit(repo, {event: 'issue_comment', action: 'created', repo: fullName(repo), number: item.number, sender: token.login, commentId: comment.id}); + }); + + route('GET', '/api/repos/:owner/:repo/labels', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (repo) { + sendPage(ctx, repo.labels.map((l) => state.labelJSON(repo, l))); + } + }); + + route('GET', '/api/repos/:owner/:repo/assignees', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (repo) { + sendPage(ctx, [...repo.writers].sort().map((login) => state.userJSON(state.login(login)))); + } + }); + + route('GET', '/api/repos/:owner/:repo/milestones', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (repo) { + const wanted = ctx.url.searchParams.get('state') ?? 'open'; + sendPage(ctx, repo.milestones.filter((m) => wanted === 'all' || m.state === wanted).map((m) => state.milestoneJSON(repo, m))); + } + }); + + route('GET', '/api/repos/:owner/:repo/pulls/:number', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + const item = repo && state.item(repo.owner, repo.name, Number(ctx.params[2])); + if (repo) { + return item?.kind === 'pull' ? ctx.send(200, state.pullJSON(item, ctx.token)) : notFound(ctx); + } + }); + + route('GET', '/api/repos/:owner/:repo/pulls/:number/reviews', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + const item = repo && state.item(repo.owner, repo.name, Number(ctx.params[2])); + if (repo) { + return item?.kind === 'pull' ? sendPage(ctx, item.reviews.map((r) => state.reviewJSON(item, r))) : notFound(ctx); + } + }); + + route('GET', '/api/repos/:owner/:repo/commits/:ref/status', (ctx) => { + const repo = visibleRepo(ctx, ctx.params[0], ctx.params[1]); + const item = repo && state.items.find((i) => i.repo === fullName(repo) && [sha(i, 'head'), i.head].includes(ctx.params[2])); + if (repo) { + return item ? ctx.send(200, state.combinedStatusJSON(item)) : notFound(ctx); + } + }); + + route('GET', '/api/search/issues', (ctx) => { + const q = ctx.url.searchParams.get('q'); + if (!q) { + return ctx.send(422, {message: 'Validation Failed', errors: [{resource: 'Search', field: 'q', code: 'missing'}], documentation_url: docs, status: '422'}); + } + const {visible, ssoHeader} = searchVisible(ctx, q); + const {items, headers} = paginate(ctx, visible.map((i) => state.issueJSON(i, 'issue-search-result-item'))); + ctx.send(200, {total_count: visible.length, incomplete_results: false, search_type: 'lexical', items}, {...headers, ...ssoHeader}); + }); + + route('GET', '/api/notifications', (ctx) => { + const token = requireToken(ctx); + if (!token) { + return; + } + if (!hasScope(token.scopes, 'notifications', 'repo')) { + return fail(ctx, 403, "Missing the 'notifications' scope."); + } + const all = ctx.url.searchParams.get('all') === 'true'; + const threads = state.threads.filter((t) => t.login === token.login && (all || t.unread) && state.canSee(token, state.repos.get(t.repo.toLowerCase()))); + sendPage(ctx, threads.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map((t) => state.threadJSON(t, token)), {defaultSize: 50, maxSize: 50}); + }); + + route('POST', '/api/graphql', async (ctx) => { + if (!ctx.token) { + return ctx.send(401, {message: 'This endpoint requires you to be authenticated.', documentation_url: 'https://docs.github.com/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql', status: '401'}); + } + const {document, errors} = validateGraphQLQuery(String(ctx.body?.query ?? '')); + if (!document || errors.length) { + violations.push(...errors.map((e) => `graphql query: ${e}`)); + return ctx.send(200, {errors: errors.map((message) => ({message}))}); + } + const result = await execute({schema: graphQLSchema, document, variableValues: ctx.body.variables, rootValue: graphQLRoot(ctx)}); + for (const error of result.errors ?? []) { + if (!(error.originalError instanceof GitHubGraphQLError)) { + violations.push(`graphql answer: ${error.message} at ${error.path?.join('.')}`); + } + } + ctx.send(200, result); + }); + + route('GET', '/avatars/u/:id', (ctx) => { + const hue = (Number(ctx.params[0]) * 47) % 360; + ctx.send(200, ``, {'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=3600'}); + }); + + route('GET', '/', (ctx) => ctx.send(200, 'ok', {'Content-Type': 'text/plain'})); + + route('POST', '/__control/reset', async (ctx) => { + await Promise.allSettled(pending); + state = new State(); + journal = []; + violations = []; + faults = []; + pending = []; + rateLimit = new Map(); + oauthApp = {...app, ...ctx.body}; + ctx.send(200, {}); + }); + route('POST', '/__control/oauth-app', (ctx) => { + oauthApp = {...oauthApp, ...ctx.body}; + ctx.send(200, {}); + }); + route('GET', '/__control/requests', (ctx) => ctx.send(200, journal)); + route('GET', '/__control/contract-violations', (ctx) => ctx.send(200, violations)); + route('POST', '/__control/users', (ctx) => ctx.send(200, state.addUser(ctx.body.login, {name: ctx.body.name ?? null, email: ctx.body.email ?? null}))); + route('POST', '/__control/orgs', (ctx) => { + const org = state.addOrg(ctx.body.login, ctx.body.members ?? [], ctx.body.admins ?? []); + org.ssoEnforced = Boolean(ctx.body.ssoEnforced); + ctx.send(200, {...org, members: [...org.members], admins: [...org.admins]}); + }); + route('POST', '/__control/sso', (ctx) => { + state.orgs.get(String(ctx.body.org).toLowerCase())!.ssoEnforced = Boolean(ctx.body.enforced); + ctx.send(200, {}); + }); + route('POST', '/__control/teams', (ctx) => { + const team = {id: state.id(), org: ctx.body.org, slug: ctx.body.slug, name: ctx.body.name ?? ctx.body.slug, members: new Set(ctx.body.members ?? [])}; + state.teams.push(team); + ctx.send(200, {...team, members: [...team.members]}); + }); + route('POST', '/__control/repos', (ctx) => { + const repo = state.addRepo(ctx.body.fullName, ctx.body); + ctx.send(200, {fullName: fullName(repo), id: repo.id}); + }); + route('POST', '/__control/items', (ctx) => { + const [owner, name] = String(ctx.body.repo).split('/'); + const {repo: _, ...fields} = ctx.body; + ctx.send(200, state.addItem(state.repo(owner, name)!, fields)); + }); + route('PATCH', '/__control/items', (ctx) => { + const [owner, name] = String(ctx.body.repo).split('/'); + const item = state.item(owner, name, ctx.body.number); + if (!item) { + return ctx.send(404, {}); + } + const {repo: _, number: __, ...patch} = ctx.body; + Object.assign(item, patch, {updatedAt: state.now()}); + ctx.send(200, item); + }); + route('GET', '/__control/items', (ctx) => ctx.send(200, state.items)); + route('POST', '/__control/threads', (ctx) => { + const item = ctx.body.number ? state.item(...(String(ctx.body.repo).split('/') as [string, string]), ctx.body.number) : undefined; + const thread = { + id: String(state.id()), login: ctx.body.login, repo: ctx.body.repo, number: ctx.body.number ?? null, reason: ctx.body.reason ?? 'mention', + title: ctx.body.title ?? item?.title ?? 'Notification', type: ctx.body.type ?? (item?.kind === 'pull' ? 'PullRequest' : 'Issue'), unread: true, updatedAt: state.now(), + }; + state.threads.push(thread); + ctx.send(200, thread); + }); + route('POST', '/__control/hooks', (ctx) => { + const hook: Hook = {id: state.id(), owner: ctx.body.owner, repo: ctx.body.repo, url: ctx.body.url, secret: ctx.body.secret ?? '', events: ctx.body.events ?? ['*'], active: true, contentType: ctx.body.contentType ?? 'json', createdAt: state.now()}; + state.hooks.push(hook); + ctx.send(200, hook); + }); + route('GET', '/__control/hooks', (ctx) => ctx.send(200, state.hooks)); + route('POST', '/__control/tokens', (ctx) => { + const {login} = state.login(ctx.body.login); + const scopes = state.grant(login, oauthApp.clientId, ctx.body.scopes ?? defaultScopes); + ctx.send(200, state.issueToken(login, oauthApp.clientId, scopes)); + }); + route('POST', '/__control/grants', (ctx) => ctx.send(200, {scopes: state.grant(ctx.body.login, oauthApp.clientId, ctx.body.scopes)})); + route('POST', '/__control/revoke', (ctx) => { + const token = state.tokens.get(ctx.body.token); + if (token) { + token.revoked = true; + } + ctx.send(200, {}); + }); + route('POST', '/__control/sign-in', (ctx) => { + state.signedIn = ctx.body.login ? state.login(ctx.body.login).login : null; + ctx.send(200, {}); + }); + route('POST', '/__control/fault', (ctx) => { + let pattern: RegExp; + try { + pattern = new RegExp(`^${ctx.body.path}$`); + } catch (err) { + return ctx.send(400, {message: String(err)}); + } + faults.push({times: Infinity, ...ctx.body, pattern}); + return ctx.send(200, {}); + }); + route('POST', '/__control/webhooks', async (ctx) => { + const request = ctx.body as WebhookRequest & {deliver?: boolean}; + const payload = buildPayload(state, request); + violations.push(...validateWebhook(request.event, payload)); + let deliveries: Delivery[] = []; + if (request.deliver !== false) { + const [owner, name] = request.repo.split('/'); + deliveries = await deliver(hooksFor(state.repo(owner, name)!), request.event, payload); + } + ctx.send(200, {payload, deliveries}); + }); + route('POST', '/__control/settle', async (ctx) => { + await Promise.allSettled(pending); + ctx.send(200, {}); + }); + + function orgHookAccess(ctx: Ctx): Org | null { + const token = requireToken(ctx); + const org = state.orgs.get(ctx.params[0].toLowerCase()); + if (!token || ssoBlocked(ctx, ctx.params[0])) { + return null; + } + if (!org || !org.admins.has(token.login) || !hasScope(token.scopes, 'admin:org_hook')) { + notFound(ctx); + return null; + } + return org; + } + + function repoHookAccess(ctx: Ctx, scope: string): Repo | null { + const token = requireToken(ctx); + const repo = token && visibleRepo(ctx, ctx.params[0], ctx.params[1]); + if (!token || !repo) { + return null; + } + if (!repo.admins.has(token.login) || !hasScope(token.scopes, scope, 'repo')) { + notFound(ctx); + return null; + } + return repo; + } + + function createHook(ctx: Ctx, owner: string, repo: string | undefined) { + const config = ctx.body?.config ?? {}; + if (!config.url) { + return ctx.send(422, {message: 'Validation Failed', errors: [{resource: 'Hook', code: 'custom', message: 'Config must contain URL for webhooks'}], documentation_url: docs, status: '422'}); + } + const existing = state.hooks.find((h) => h.owner === owner && h.repo === repo && h.url === config.url); + if (existing) { + return ctx.send(422, {message: 'Validation Failed', errors: [{resource: 'Hook', code: 'custom', message: `Hook already exists on this ${repo ? 'repository' : 'organization'}`}], documentation_url: docs, status: '422'}); + } + const hook: Hook = {id: state.id(), owner, repo, url: config.url, secret: config.secret ?? '', events: ctx.body.events ?? ['push'], active: ctx.body.active ?? true, contentType: config.content_type ?? 'form', createdAt: state.now()}; + state.hooks.push(hook); + ctx.send(201, state.hookJSON(hook, repo ? 'hook' : 'org-hook')); + const payload = pingPayload(state, hook, ctx.token!.login); + violations.push(...validateWebhook('ping', payload)); + track(deliver([hook], 'ping', payload)); + } + + function issueCode(ctx: Ctx, redirectURI: string, scopes: string[], stateParam: string) { + const code: AuthCode = {code: crypto.randomBytes(10).toString('hex'), login: state.signedIn!, clientId: oauthApp.clientId, redirectUri: redirectURI, scopes, expiresAt: Date.now() + (oauthApp.codeTTLms ?? 10 * 60_000), used: false}; + state.codes.set(code.code, code); + redirect(ctx, redirectURI, {code: code.code, state: stateParam}); + } + + // Search drops results from SAML-protected orgs the token isn't authorized for, and says so in a header. + function searchVisible(ctx: Ctx, query: string): {visible: Item[]; ssoHeader: Record} { + const hits = state.search(query, ctx.token); + const blocked = new Set(); + const visible = hits.filter((item) => { + const org = state.orgs.get(state.repoOf(item).owner.toLowerCase()); + if (org?.ssoEnforced && ctx.token && org.members.has(ctx.token.login)) { + blocked.add(org); + return false; + } + return true; + }); + return {visible, ssoHeader: blocked.size ? {'X-GitHub-SSO': `partial-results; organizations=${[...blocked].map((o) => o.id).join(',')}`} : {}}; + } + + function graphQLRoot(ctx: Ctx) { + return { + search: ({query, type, first, last, after}: {query: string; type: string; first?: number; last?: number; after?: string}) => { + if (first === undefined && last === undefined) { + throw new GitHubGraphQLError('You must provide a `first` or `last` value to properly paginate the `search` connection.'); + } + if (last !== undefined) { + throw new Error('fake-github does not implement `last` on search; add it if the plugin starts using it'); + } + const limit = first!; + if (limit > 100) { + throw new GitHubGraphQLError(`Requesting ${limit} records on the \`search\` connection exceeds the \`${first === undefined ? 'last' : 'first'}\` limit of 100 records.`); + } + const hits = type === 'ISSUE' ? searchVisible(ctx, query).visible : []; + const start = after ? Number(Buffer.from(after, 'base64').toString().replace('cursor:', '')) : 0; + const nodes = hits.slice(start, start + limit); + const end = start + nodes.length; + return { + issueCount: hits.length, + nodes: nodes.map(graphQLItem), + pageInfo: {endCursor: nodes.length ? Buffer.from(`cursor:${end}`).toString('base64') : null, hasNextPage: end < hits.length}, + }; + }, + }; + } + + function graphQLItem(item: Item) { + const repo = state.repoOf(item); + const actor = (login: string) => ({__typename: state.account(login)?.type === 'Organization' ? 'Organization' : 'User', login}); + const issue = state.issueJSON(item); + const milestone = repo.milestones.find((m) => m.number === item.milestone); + return { + __typename: item.kind === 'pull' ? 'PullRequest' : 'Issue', + number: item.number, title: item.title, body: item.body, url: issue.html_url, + state: item.merged ? 'MERGED' : item.state.toUpperCase(), createdAt: item.createdAt, updatedAt: item.updatedAt, + authorAssociation: state.association(repo, item.author), author: actor(item.author), + repository: {name: repo.name, nameWithOwner: fullName(repo), url: `${WEB}/${fullName(repo)}`, owner: actor(repo.owner)}, + milestone: milestone ? {title: milestone.title} : null, + labels: ({first}: {first?: number}) => ({nodes: item.labels.slice(0, first ?? 100).map((name) => ({name, color: repo.labels.find((l) => l.name === name)?.color ?? 'ededed'}))}), + additions: item.additions, deletions: item.deletions, changedFiles: item.changedFiles, + reviewRequests: ({first}: {first?: number}) => ({ + nodes: [ + ...item.reviewers.map((login) => ({requestedReviewer: {__typename: 'User', login}})), + ...item.teamReviewers.map((slug) => ({requestedReviewer: {__typename: 'Team', slug, organization: {__typename: 'Organization', login: repo.owner}}})), + ].slice(0, first ?? 100), + }), + }; + } + + const listener = async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', publicURL); + const rawBody = await readBody(req); + const isControl = url.pathname.startsWith('/__control/'); + let body: any = {}; + if (rawBody) { + body = (req.headers['content-type'] ?? '').includes('json') ? safeJSON(rawBody) : Object.fromEntries(new URLSearchParams(rawBody)); + } + const bearer = /^(?:Bearer|token) (.+)$/i.exec(req.headers.authorization ?? '')?.[1]; + const known = bearer ? state.tokens.get(bearer) : undefined; + const token = known && !known.revoked ? known : null; + const apiPath = url.pathname.startsWith('/api/') ? url.pathname.slice(4) : null; + + let skipContract = false; + const send: Ctx['send'] = (status, payload, headers = {}) => { + if (res.headersSent) { + return; + } + const isText = typeof payload === 'string'; + const out = payload === undefined ? '' : isText ? payload : JSON.stringify(payload); + if (apiPath && apiPath !== '/graphql' && !isText && !skipContract) { + violations.push(...validateResponse(req.method!, apiPath, status, payload)); + } + res.writeHead(status, { + 'Content-Type': isText ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8', + ...(apiPath ? apiHeaders(token, rateLimit) : {}), + ...headers, + }); + res.end(out); + if (!isControl) { + journal.push({method: req.method!, path: url.pathname, query: Object.fromEntries(url.searchParams), status, body: rawBody ? body : undefined}); + } + }; + const ctx: Ctx = {req, res, url, params: [], body, token, send}; + + try { + if (apiPath && bearer && !token) { + return send(401, {message: 'Bad credentials', documentation_url: docs, status: '401'}); + } + if (body === invalidJSON) { + violations.push(`${req.method} ${url.pathname}: the plugin sent a body that is not JSON`); + skipContract = true; + return send(400, {message: 'Problems parsing JSON', documentation_url: docs, status: '400'}); + } + if (apiPath && apiPath !== '/graphql' && !isControl && req.method !== 'GET' && rawBody) { + violations.push(...validateRequest(req.method!, apiPath, body)); + } + const fault = !isControl && faults.find((f) => f.times > 0 && f.method === req.method && f.pattern.test(url.pathname)); + if (fault) { + fault.times--; + skipContract = true; + return send(fault.status, fault.body ?? {message: 'Injected fault', documentation_url: docs, status: String(fault.status)}, fault.headers); + } + for (const [method, pattern, handler] of routes) { + const match = method === req.method && pattern.exec(url.pathname); + if (match) { + ctx.params = match.slice(1).map(decodeURIComponent); + await handler(ctx); + return; + } + } + if (apiPath) { + violations.push(`${req.method} ${apiPath} is not served by fake-github`); + skipContract = true; + } + send(404, {message: 'Not Found', documentation_url: docs, status: '404'}); + } catch (err) { + violations.push(`fake-github crashed on ${req.method} ${url.pathname}: ${(err as Error).stack}`); + send(500, {message: String(err)}); + } + }; + + const server: Server = createServer((req, res) => { + listener(req, res).catch((err) => { + res.statusCode = 500; + res.end(String(err)); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, resolve); + }); + + return { + get state() { + return state; + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +export const defaultScopes = ['public_repo', 'notifications', 'read:org', 'admin:org_hook']; + +class GitHubGraphQLError extends GraphQLError {} + +function apiHeaders(token: Token | null, rateLimit: Map): Record { + const key = token?.value ?? 'anonymous'; + const used = (rateLimit.get(key) ?? 0) + 1; + rateLimit.set(key, used); + const limit = token ? 5000 : 60; + return { + 'X-GitHub-Media-Type': 'github.v3; format=json', + 'X-GitHub-Api-Version-Selected': '2022-11-28', + 'X-GitHub-Request-Id': crypto.randomBytes(8).toString('hex').toUpperCase(), + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': String(Math.max(limit - used, 0)), + 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600), + 'X-RateLimit-Used': String(used), + 'X-RateLimit-Resource': 'core', + ...(token ? {'X-OAuth-Scopes': normalizeScopes(token.scopes).join(', '), 'X-Accepted-OAuth-Scopes': ''} : {}), + }; +} + +function redirectMatches(requested: string, registered: string): boolean { + try { + const a = new URL(requested); + const b = new URL(registered); + return a.protocol === b.protocol && a.host === b.host && (a.pathname === b.pathname || a.pathname.startsWith(`${b.pathname.replace(/\/$/, '')}/`)); + } catch { + return false; + } +} + +function redirect(ctx: Ctx, target: string, params: Record) { + const url = new URL(target); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + ctx.send(302, '', {Location: url.toString()}); +} + +function parseScopeParam(scope: string | null | undefined): string[] { + return normalizeScopes((scope ?? '').split(/[\s,]+/)); +} + +function sortRepos(repos: Repo[]): Repo[] { + return [...repos].sort((a, b) => fullName(a).localeCompare(fullName(b))); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', reject); + }); +} + +const invalidJSON = Symbol('invalid JSON'); + +function safeJSON(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return invalidJSON; + } +} + +function escape(value: string): string { + return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +} + +function hidden(fields: Record): string { + return Object.entries(fields).map(([k, v]) => ``).join(''); +} + +function htmlPage(title: string, content: string): string { + return `${escape(title)}
${content}
`; +} diff --git a/e2e/integration/fake-github/state.ts b/e2e/integration/fake-github/state.ts new file mode 100644 index 000000000..4c24a94e2 --- /dev/null +++ b/e2e/integration/fake-github/state.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import crypto from 'node:crypto'; + +import {merge, skeleton} from './contract/validator'; + +export const API = 'https://api.github.com'; +export const WEB = 'https://github.com'; + +export type Account = {id: number; login: string; type: 'User' | 'Organization'; name: string | null; email: string | null; createdAt: string}; +export type Org = Account & {members: Set; admins: Set; ssoEnforced: boolean}; +export type Team = {id: number; org: string; slug: string; name: string; members: Set}; +export type Label = {id: number; name: string; color: string; description: string | null}; +export type Milestone = {id: number; number: number; title: string; state: 'open' | 'closed'}; +export type Hook = {id: number; owner: string; repo?: string; url: string; secret: string; events: string[]; active: boolean; contentType: string; createdAt: string}; +export type Comment = {id: number; author: string; body: string; createdAt: string}; +export type Repo = { + id: number; owner: string; name: string; private: boolean; archived: boolean; description: string | null; + admins: Set; writers: Set; labels: Label[]; milestones: Milestone[]; nextNumber: number; createdAt: string; +}; +export type Item = { + id: number; repo: string; number: number; kind: 'issue' | 'pull'; title: string; body: string; + state: 'open' | 'closed'; merged: boolean; draft: boolean; author: string; assignees: string[]; labels: string[]; + milestone: number | null; reviewers: string[]; teamReviewers: string[]; head: string; base: string; + additions: number; deletions: number; changedFiles: number; comments: Comment[]; createdAt: string; updatedAt: string; + reviews: Review[]; ciState: 'success' | 'pending' | 'failure'; +}; +export type Review = {id: number; author: string; state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED'; submittedAt: string}; +export type Thread = {id: string; login: string; repo: string; number: number | null; title: string; type: 'Issue' | 'PullRequest' | 'Commit' | 'Release'; reason: string; unread: boolean; updatedAt: string}; +export type Token = {value: string; login: string; clientId: string; scopes: string[]; revoked: boolean}; +export type AuthCode = {code: string; login: string; clientId: string; redirectUri: string; scopes: string[]; expiresAt: number; used: boolean}; + +// GitHub folds scopes that another granted scope already covers. +const impliedBy: Record = { + public_repo: 'repo', 'repo:status': 'repo', repo_deployment: 'repo', 'repo:invite': 'repo', security_events: 'repo', + 'read:org': 'write:org', 'write:org': 'admin:org', 'read:user': 'user', 'user:email': 'user', 'user:follow': 'user', + 'read:repo_hook': 'write:repo_hook', 'write:repo_hook': 'admin:repo_hook', 'read:public_key': 'write:public_key', 'write:public_key': 'admin:public_key', +}; + +export function normalizeScopes(scopes: Iterable): string[] { + const set = new Set([...scopes].map((s) => s.trim()).filter(Boolean)); + const covered = (scope: string): boolean => { + const parent = impliedBy[scope]; + return Boolean(parent) && (set.has(parent) || covered(parent)); + }; + return [...set].filter((s) => !covered(s)).sort(); +} + +export function hasScope(granted: string[], ...accepted: string[]): boolean { + return accepted.some((scope) => { + for (let s: string | undefined = scope; s; s = impliedBy[s]) { + if (granted.includes(s)) { + return true; + } + } + return false; + }); +} + +export class State { + accounts = new Map(); + orgs = new Map(); + teams: Team[] = []; + repos = new Map(); + items: Item[] = []; + hooks: Hook[] = []; + threads: Thread[] = []; + tokens = new Map(); + codes = new Map(); + grants = new Map(); + signedIn: string | null = null; + private nextId = 1000; + + id(): number { + return this.nextId++; + } + + now(): string { + return new Date().toISOString().replace(/\.\d+Z$/, 'Z'); + } + + addUser(login: string, extra: Partial = {}): Account { + const account: Account = {id: this.id(), login, type: 'User', name: null, email: null, createdAt: this.now(), ...extra}; + this.accounts.set(login.toLowerCase(), account); + return account; + } + + addOrg(login: string, members: string[] = [], admins: string[] = []): Org { + const org: Org = {id: this.id(), login, type: 'Organization', name: null, email: null, createdAt: this.now(), members: new Set([...members, ...admins]), admins: new Set(admins), ssoEnforced: false}; + this.accounts.set(login.toLowerCase(), org); + this.orgs.set(login.toLowerCase(), org); + return org; + } + + account(login: string): Account | undefined { + return this.accounts.get(login.toLowerCase()); + } + + addRepo(repoName: string, options: Partial> & {admins?: string[]; writers?: string[]; labels?: string[]; milestones?: string[]} = {}): Repo { + const [owner, name] = repoName.split('/'); + const ownerAccount = this.account(owner); + if (!ownerAccount) { + throw new Error(`unknown owner ${owner}`); + } + const admins = new Set(options.admins ?? (ownerAccount.type === 'User' ? [owner] : [...this.orgs.get(owner.toLowerCase())!.admins])); + const repo: Repo = { + id: this.id(), owner: ownerAccount.login, name, private: options.private ?? false, archived: options.archived ?? false, + description: options.description ?? null, admins, writers: new Set([...admins, ...(options.writers ?? [])]), + labels: (options.labels ?? ['bug', 'enhancement']).map((l, i) => ({id: this.id(), name: l, color: ['d73a4a', 'a2eeef', '0075ca', '7057ff'][i % 4], description: null})), + milestones: (options.milestones ?? []).map((title, i) => ({id: this.id(), number: i + 1, title, state: 'open' as const})), + nextNumber: 1, createdAt: this.now(), + }; + this.repos.set(repoName.toLowerCase(), repo); + return repo; + } + + repo(owner: string, name: string): Repo | undefined { + return this.repos.get(`${owner}/${name}`.toLowerCase()); + } + + addItem(repo: Repo, fields: Partial & Pick): Item { + const now = this.now(); + const number = repo.nextNumber++; + const item: Item = { + id: this.id(), repo: fullName(repo), number, body: '', state: 'open', merged: false, draft: false, + assignees: [], labels: [], milestone: null, reviewers: [], teamReviewers: [], head: `feature-${number}`, base: 'main', + additions: 10, deletions: 2, changedFiles: 1, comments: [], reviews: [], ciState: 'success', createdAt: now, updatedAt: now, ...fields, + }; + this.items.push(item); + return item; + } + + item(owner: string, name: string, number: number): Item | undefined { + const key = `${owner}/${name}`.toLowerCase(); + return this.items.find((i) => i.repo.toLowerCase() === key && i.number === number); + } + + repoOf(item: Item): Repo { + return this.repos.get(item.repo.toLowerCase())!; + } + + issueToken(login: string, clientId: string, scopes: string[]): Token { + const value = `gho_${crypto.randomBytes(27).toString('base64url').replace(/[-_]/g, 'x').slice(0, 36)}`; + const token: Token = {value, login, clientId, scopes: normalizeScopes(scopes), revoked: false}; + this.tokens.set(value, token); + return token; + } + + // A classic OAuth app's token carries every scope the user ever granted that app. + grant(login: string, clientId: string, requested: string[]): string[] { + const key = `${clientId}:${login.toLowerCase()}`; + const scopes = normalizeScopes([...(this.grants.get(key) ?? []), ...requested]); + this.grants.set(key, scopes); + return scopes; + } + + canSee(viewer: Token | null, repo: Repo | undefined): boolean { + if (!repo) { + return false; + } + if (!repo.private) { + return true; + } + if (!viewer || !hasScope(viewer.scopes, 'repo')) { + return false; + } + const org = this.orgs.get(repo.owner.toLowerCase()); + return repo.writers.has(viewer.login) || repo.owner === viewer.login || Boolean(org?.members.has(viewer.login)); + } + + permissions(viewer: Token | null, repo: Repo) { + const login = viewer?.login ?? ''; + const admin = repo.admins.has(login); + const push = repo.writers.has(login); + return {admin, maintain: admin, push, triage: push, pull: true}; + } + + userJSON(account: Account, schema: 'simple-user' | 'public-user' | 'private-user' = 'simple-user') { + const login = account.login; + const detail = schema === 'simple-user' ? {} : { + name: account.name, email: account.email, created_at: account.createdAt, updated_at: account.createdAt, + public_repos: [...this.repos.values()].filter((r) => r.owner === login && !r.private).length, + }; + return merge(skeleton(schema), { + login, id: account.id, node_id: nodeId(account.type === 'User' ? 'U' : 'O', account.id), type: account.type, gravatar_id: '', + avatar_url: `${avatarBase}/u/${account.id}?v=4`, html_url: `${WEB}/${login}`, url: `${API}/users/${login}`, + repos_url: `${API}/users/${login}/repos`, organizations_url: `${API}/users/${login}/orgs`, + ...detail, + }); + } + + login(login: string): Account { + return this.account(login) ?? this.addUser(login); + } + + orgJSON(org: Org, schema: 'organization-simple' | 'organization-full' = 'organization-simple') { + return merge(skeleton(schema), { + login: org.login, id: org.id, node_id: nodeId('O', org.id), url: `${API}/orgs/${org.login}`, + repos_url: `${API}/orgs/${org.login}/repos`, hooks_url: `${API}/orgs/${org.login}/hooks`, + avatar_url: `${avatarBase}/u/${org.id}?v=4`, description: null, + ...(schema === 'organization-full' ? {html_url: `${WEB}/${org.login}`, type: 'Organization', created_at: org.createdAt, updated_at: org.createdAt} : {}), + }); + } + + repoJSON(repo: Repo, viewer: Token | null, schema: 'minimal-repository' | 'repository' | 'full-repository' = 'repository') { + const name = fullName(repo); + return merge(skeleton(schema), { + id: repo.id, node_id: nodeId('R', repo.id), name: repo.name, full_name: name, private: repo.private, archived: repo.archived, + visibility: repo.private ? 'private' : 'public', description: repo.description, default_branch: 'main', + owner: this.userJSON(this.account(repo.owner)!), + html_url: `${WEB}/${name}`, url: `${API}/repos/${name}`, hooks_url: `${API}/repos/${name}/hooks`, + issues_url: `${API}/repos/${name}/issues{/number}`, pulls_url: `${API}/repos/${name}/pulls{/number}`, + clone_url: `${WEB}/${name}.git`, svn_url: `${WEB}/${name}`, git_url: `git://github.com/${name}.git`, ssh_url: `git@github.com:${name}.git`, + has_issues: true, open_issues_count: this.items.filter((i) => i.repo === name && i.state === 'open').length, + created_at: repo.createdAt, updated_at: repo.createdAt, pushed_at: repo.createdAt, + permissions: this.permissions(viewer, repo), + }); + } + + labelJSON(repo: Repo, label: Label) { + return merge(skeleton('label'), {id: label.id, node_id: nodeId('LA', label.id), url: `${API}/repos/${fullName(repo)}/labels/${encodeURIComponent(label.name)}`, name: label.name, color: label.color, description: label.description}); + } + + milestoneJSON(repo: Repo, milestone: Milestone) { + const base = `${API}/repos/${fullName(repo)}/milestones/${milestone.number}`; + return merge(skeleton('milestone'), { + id: milestone.id, node_id: nodeId('MI', milestone.id), number: milestone.number, title: milestone.title, state: milestone.state, + url: base, labels_url: `${base}/labels`, html_url: `${WEB}/${fullName(repo)}/milestone/${milestone.number}`, + created_at: repo.createdAt, updated_at: repo.createdAt, + }); + } + + issueJSON(item: Item, schema: 'issue' | 'issue-search-result-item' = 'issue') { + const repo = this.repoOf(item); + const name = fullName(repo); + const kindPath = item.kind === 'pull' ? 'pull' : 'issues'; + const milestone = repo.milestones.find((m) => m.number === item.milestone); + return merge(skeleton(schema), { + id: item.id, node_id: nodeId(item.kind === 'pull' ? 'PR' : 'I', item.id), number: item.number, title: item.title, body: item.body, + state: item.state, state_reason: null, draft: item.kind === 'pull' ? item.draft : undefined, locked: false, comments: item.comments.length, + user: this.userJSON(this.login(item.author)), assignee: item.assignees[0] ? this.userJSON(this.login(item.assignees[0])) : null, + assignees: item.assignees.map((a) => this.userJSON(this.login(a))), + labels: item.labels.map((l) => this.labelJSON(repo, repo.labels.find((x) => x.name === l) ?? {id: 0, name: l, color: 'ededed', description: null})), + milestone: milestone ? this.milestoneJSON(repo, milestone) : null, + url: `${API}/repos/${name}/issues/${item.number}`, html_url: `${WEB}/${name}/${kindPath}/${item.number}`, + repository_url: `${API}/repos/${name}`, comments_url: `${API}/repos/${name}/issues/${item.number}/comments`, + labels_url: `${API}/repos/${name}/issues/${item.number}/labels{/name}`, events_url: `${API}/repos/${name}/issues/${item.number}/events`, + author_association: this.association(repo, item.author), + closed_at: item.state === 'closed' ? item.updatedAt : null, created_at: item.createdAt, updated_at: item.updatedAt, + ...(item.kind === 'pull' ? {pull_request: {url: `${API}/repos/${name}/pulls/${item.number}`, html_url: `${WEB}/${name}/pull/${item.number}`, diff_url: `${WEB}/${name}/pull/${item.number}.diff`, patch_url: `${WEB}/${name}/pull/${item.number}.patch`, merged_at: item.merged ? item.updatedAt : null}} : {}), + ...(schema === 'issue-search-result-item' ? {score: 1} : {}), + }); + } + + pullJSON(item: Item, viewer: Token | null) { + const repo = this.repoOf(item); + const name = fullName(repo); + const base = `${API}/repos/${name}/pulls/${item.number}`; + const ref = (branch: string, commit: string) => ({label: `${repo.owner}:${branch}`, ref: branch, sha: commit, user: this.userJSON(this.account(repo.owner)!), repo: this.repoJSON(repo, viewer)}); + const link = (href: string) => ({href}); + const issue = this.issueJSON(item); + return merge(skeleton('pull-request'), { + ...issue, pull_request: undefined, repository_url: undefined, labels_url: undefined, events_url: undefined, comments: item.comments.length, + url: base, html_url: `${WEB}/${name}/pull/${item.number}`, diff_url: `${WEB}/${name}/pull/${item.number}.diff`, patch_url: `${WEB}/${name}/pull/${item.number}.patch`, + issue_url: `${API}/repos/${name}/issues/${item.number}`, commits_url: `${base}/commits`, review_comments_url: `${base}/comments`, + review_comment_url: `${API}/repos/${name}/pulls/comments{/number}`, comments_url: `${API}/repos/${name}/issues/${item.number}/comments`, + statuses_url: `${API}/repos/${name}/statuses/${sha(item, 'head')}`, + head: ref(item.head, sha(item, 'head')), base: ref(item.base, sha(item, 'base')), + _links: {self: link(base), html: link(`${WEB}/${name}/pull/${item.number}`), issue: link(`${API}/repos/${name}/issues/${item.number}`), comments: link(`${API}/repos/${name}/issues/${item.number}/comments`), review_comments: link(`${base}/comments`), review_comment: link(`${API}/repos/${name}/pulls/comments{/number}`), commits: link(`${base}/commits`), statuses: link(`${API}/repos/${name}/statuses/${sha(item, 'head')}`)}, + requested_reviewers: item.reviewers.map((r) => this.userJSON(this.login(r))), + requested_teams: item.teamReviewers.map((slug) => this.teamJSON(this.teams.find((t) => t.org.toLowerCase() === repo.owner.toLowerCase() && t.slug === slug)!)), + merged: item.merged, merged_at: item.merged ? item.updatedAt : null, mergeable: item.state === 'open' ? true : null, mergeable_state: 'clean', + merge_commit_sha: null, draft: item.draft, auto_merge: null, merged_by: null, + additions: item.additions, deletions: item.deletions, changed_files: item.changedFiles, commits: 1, review_comments: 0, maintainer_can_modify: false, + }); + } + + reviewJSON(item: Item, review: Review) { + const html = `${WEB}/${item.repo}/pull/${item.number}#pullrequestreview-${review.id}`; + return merge(skeleton('pull-request-review'), { + id: review.id, node_id: nodeId('PRR', review.id), user: this.userJSON(this.login(review.author)), body: '', state: review.state, + html_url: html, pull_request_url: `${API}/repos/${item.repo}/pulls/${item.number}`, submitted_at: review.submittedAt, commit_id: sha(item, 'head'), + author_association: this.association(this.repoOf(item), review.author), + _links: {html: {href: html}, pull_request: {href: `${API}/repos/${item.repo}/pulls/${item.number}`}}, + }); + } + + combinedStatusJSON(item: Item) { + const repo = this.repoOf(item); + const head = sha(item, 'head'); + const url = `${API}/repos/${item.repo}/commits/${head}/status`; + return merge(skeleton('combined-commit-status'), { + state: item.ciState, sha: head, total_count: 1, commit_url: `${API}/repos/${item.repo}/commits/${head}`, url, + repository: this.repoJSON(repo, null, 'minimal-repository'), + statuses: [merge(skeleton('simple-commit-status'), {id: item.id, node_id: nodeId('SC', item.id), state: item.ciState, context: 'ci/e2e', description: null, target_url: null, avatar_url: null, url, created_at: item.updatedAt, updated_at: item.updatedAt})], + }); + } + + teamJSON(team: Team) { + const url = `${API}/orgs/${team.org}/teams/${team.slug}`; + return merge(skeleton('team-simple'), {id: team.id, node_id: nodeId('T', team.id), name: team.name, slug: team.slug, url, html_url: `${WEB}/orgs/${team.org}/teams/${team.slug}`, members_url: `${url}/members{/member}`, repositories_url: `${url}/repos`, permission: 'pull', type: 'organization', description: null}); + } + + hookJSON(hook: Hook, schema: 'hook' | 'org-hook') { + const base = hook.repo ? `${API}/repos/${hook.owner}/${hook.repo}/hooks/${hook.id}` : `${API}/orgs/${hook.owner}/hooks/${hook.id}`; + return merge(skeleton(schema), { + id: hook.id, type: hook.repo ? 'Repository' : 'Organization', name: 'web', active: hook.active, events: hook.events, + config: {url: hook.url, content_type: hook.contentType, insecure_ssl: '0', ...(hook.secret ? {secret: '********'} : {})}, + url: base, ping_url: `${base}/pings`, deliveries_url: `${base}/deliveries`, created_at: hook.createdAt, updated_at: hook.createdAt, + ...(schema === 'hook' ? {test_url: `${base}/test`, last_response: {code: null, status: 'unused', message: null}} : {}), + }); + } + + threadJSON(thread: Thread, viewer: Token | null) { + const repo = this.repos.get(thread.repo.toLowerCase())!; + const subjectURL = thread.number ? `${API}/repos/${thread.repo}/${thread.type === 'PullRequest' ? 'pulls' : 'issues'}/${thread.number}` : `${API}/repos/${thread.repo}`; + return merge(skeleton('thread'), { + id: thread.id, unread: thread.unread, reason: thread.reason, updated_at: thread.updatedAt, last_read_at: null, + subject: {title: thread.title, type: thread.type, url: subjectURL, latest_comment_url: subjectURL}, + repository: this.repoJSON(repo, viewer, 'minimal-repository'), + url: `${API}/notifications/threads/${thread.id}`, subscription_url: `${API}/notifications/threads/${thread.id}/subscription`, + }); + } + + commentJSON(item: Item, comment: Comment) { + const name = item.repo; + return merge(skeleton('issue-comment'), { + id: comment.id, node_id: nodeId('IC', comment.id), body: comment.body, user: this.userJSON(this.login(comment.author)), + url: `${API}/repos/${name}/issues/comments/${comment.id}`, html_url: `${WEB}/${name}/${item.kind === 'pull' ? 'pull' : 'issues'}/${item.number}#issuecomment-${comment.id}`, + issue_url: `${API}/repos/${name}/issues/${item.number}`, author_association: this.association(this.repoOf(item), comment.author), + created_at: comment.createdAt, updated_at: comment.createdAt, + }); + } + + association(repo: Repo, login: string): string { + if (repo.owner === login) { + return 'OWNER'; + } + if (this.orgs.get(repo.owner.toLowerCase())?.members.has(login)) { + return 'MEMBER'; + } + return repo.writers.has(login) ? 'COLLABORATOR' : 'NONE'; + } + + search(query: string, viewer: Token | null): Item[] { + const terms = parseSearch(query, viewer?.login ?? ''); + return this.items. + filter((item) => this.canSee(viewer, this.repoOf(item)) && terms.every((term) => term.negated !== this.matches(item, term))). + sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id - a.id); + } + + private matches(item: Item, {qualifier, values}: SearchTerm): boolean { + const repo = this.repoOf(item); + const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + return values.some((value) => { + switch (qualifier) { + case 'is': + case 'type': + case 'state': + return matchesIs(item, repo, value.toLowerCase()); + case 'author': + return eq(item.author, value); + case 'assignee': + return item.assignees.some((a) => eq(a, value)); + case 'review-requested': + return item.reviewers.some((r) => eq(r, value)) || + item.teamReviewers.some((slug) => this.teams.some((t) => t.org.toLowerCase() === repo.owner.toLowerCase() && t.slug === slug && [...t.members].some((m) => eq(m, value)))); + case 'mentions': + return new RegExp(`@${escapeRegExp(value)}\\b`, 'i').test(`${item.body}\n${item.comments.map((c) => c.body).join('\n')}`); + case 'involves': + return eq(item.author, value) || item.assignees.some((a) => eq(a, value)) || new RegExp(`@${escapeRegExp(value)}\\b`, 'i').test(item.body); + case 'org': + case 'user': + return eq(repo.owner, value); + case 'repo': + return eq(item.repo, value); + case 'archived': + return String(repo.archived) === value.toLowerCase(); + case 'draft': + return item.kind === 'pull' && String(item.draft) === value.toLowerCase(); + case 'label': + return item.labels.some((l) => eq(l, value)); + case 'text': + return `${item.title}\n${item.body}`.toLowerCase().includes(value.toLowerCase()); + default: + return false; + } + }); + } +} + +type SearchTerm = {qualifier: string; values: string[]; negated: boolean}; + +const knownQualifiers = new Set(['is', 'type', 'state', 'author', 'assignee', 'review-requested', 'mentions', 'involves', 'org', 'user', 'repo', 'archived', 'draft', 'label']); +const orQualifiers = new Set(['org', 'user', 'repo']); + +// GitHub ANDs qualifiers, except repeated org:/user:/repo:, which it ORs. Qualifier values are case-insensitive. +export function parseSearch(query: string, viewer: string): SearchTerm[] { + const terms: SearchTerm[] = []; + for (const raw of query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) { + const negated = raw.startsWith('-'); + const token = negated ? raw.slice(1) : raw; + const colon = token.indexOf(':'); + const qualifier = colon > 0 ? token.slice(0, colon).toLowerCase() : ''; + let value = (knownQualifiers.has(qualifier) ? token.slice(colon + 1) : token).replace(/^"|"$/g, ''); + if (value === '@me') { + value = viewer; + } + const key = knownQualifiers.has(qualifier) ? qualifier : 'text'; + const existing = orQualifiers.has(key) && !negated ? terms.find((t) => t.qualifier === key && !t.negated) : undefined; + if (existing) { + existing.values.push(value); + } else { + terms.push({qualifier: key, values: [value], negated}); + } + } + return terms; +} + +function matchesIs(item: Item, repo: Repo, value: string): boolean { + switch (value) { + case 'pr': + case 'pull-request': + return item.kind === 'pull'; + case 'issue': + return item.kind === 'issue'; + case 'open': + return item.state === 'open'; + case 'closed': + return item.state === 'closed'; + case 'merged': + return item.merged; + case 'unmerged': + return item.kind === 'pull' && !item.merged; + case 'draft': + return item.draft; + case 'public': + return !repo.private; + case 'private': + return repo.private; + default: + return false; + } +} + +export function fullName(repo: Repo): string { + return `${repo.owner}/${repo.name}`; +} + +export function sha(item: Item, side: 'head' | 'base'): string { + return crypto.createHash('sha1').update(`${item.repo}#${item.number}:${side}`).digest('hex'); +} + +function nodeId(prefix: string, id: number): string { + return `${prefix}_kgDO${Buffer.from(String(id)).toString('base64url')}`; +} + +let avatarBase = 'http://fake-github:8093/avatars'; + +export function setAvatarBase(url: string) { + avatarBase = url; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/e2e/integration/fake-github/webhooks.ts b/e2e/integration/fake-github/webhooks.ts new file mode 100644 index 000000000..36504fd42 --- /dev/null +++ b/e2e/integration/fake-github/webhooks.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import crypto from 'node:crypto'; + +import {merge, skeleton, webhookSchemaName, webhookSkeleton} from './contract/validator'; +import {type Hook, type Item, type Repo, type State, WEB, fullName, sha} from './state'; + +export type WebhookRequest = {event: string; action?: string; repo: string; number?: number; sender: string; requestedReviewer?: string; comment?: string; commentId?: number; ref?: string; overrides?: Record}; + +export function buildPayload(state: State, req: WebhookRequest): Record { + const [owner, name] = req.repo.split('/'); + const repo = state.repo(owner, name); + if (!repo) { + throw new Error(`unknown repo ${req.repo}`); + } + const schemaName = webhookSchemaName(req.event, req.action); + if (!schemaName) { + throw new Error(`webhook ${req.event}.${req.action} is not in operations.json`); + } + const item = req.number === undefined ? undefined : state.item(owner, name, req.number); + const common = { + repository: repositoryWebhook(state, repo), + sender: state.userJSON(state.login(req.sender)), + ...(state.orgs.has(repo.owner.toLowerCase()) ? {organization: merge(skeleton('organization-simple-webhooks'), state.orgJSON(state.orgs.get(repo.owner.toLowerCase())!))} : {}), + }; + + let specific: Record = {}; + switch (req.event) { + case 'pull_request': + specific = { + action: req.action, number: item!.number, pull_request: merge(skeleton('pull-request-webhook'), state.pullJSON(item!, null)), + ...(req.requestedReviewer ? {requested_reviewer: state.userJSON(state.login(req.requestedReviewer))} : {}), + }; + break; + case 'issues': + specific = {action: req.action, issue: issueWebhook(state, item!)}; + break; + case 'issue_comment': { + let comment = item!.comments.find((c) => c.id === req.commentId); + if (!comment) { + comment = {id: state.id(), author: req.sender, body: req.comment ?? 'A comment', createdAt: state.now()}; + item!.comments.push(comment); + } + specific = {action: req.action, issue: issueWebhook(state, item!), comment: state.commentJSON(item!, comment)}; + break; + } + case 'push': + specific = push(state, repo, req.sender, req.ref ?? 'refs/heads/main'); + break; + default: + throw new Error(`no payload builder for ${req.event}`); + } + + return merge(merge(webhookSkeleton(schemaName), {...common, ...specific}), req.overrides ?? {}); +} + +export function pingPayload(state: State, hook: Hook, sender: string): Record { + const repo = hook.repo ? state.repo(hook.owner, hook.repo) : undefined; + const org = state.orgs.get(hook.owner.toLowerCase()); + return merge(webhookSkeleton('ping'), { + zen: 'Keep it logically awesome.', + hook_id: hook.id, + hook: state.hookJSON(hook, hook.repo ? 'hook' : 'org-hook'), + ...(repo ? {repository: repositoryWebhook(state, repo)} : {}), + ...(org ? {organization: merge(skeleton('organization-simple-webhooks'), state.orgJSON(org))} : {}), + sender: state.userJSON(state.login(sender)), + }); +} + +export type Delivery = {hookId: number; url: string; status: number; body: string}; + +export async function deliver(hooks: Hook[], event: string, payload: Record): Promise { + const body = JSON.stringify(payload); + return Promise.all(hooks.filter((h) => h.active && (event === 'ping' || h.events.includes(event) || h.events.includes('*'))).map(async (hook) => { + const sent = hook.contentType === 'form' ? `payload=${encodeURIComponent(body)}` : body; + const headers: Record = { + 'Content-Type': hook.contentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json', + 'User-Agent': 'GitHub-Hookshot/e2e0000', + 'X-GitHub-Event': event, + 'X-GitHub-Delivery': crypto.randomUUID(), + 'X-GitHub-Hook-ID': String(hook.id), + 'X-GitHub-Hook-Installation-Target-Type': hook.repo ? 'repository' : 'organization', + }; + if (hook.secret) { + Object.assign(headers, signatures(hook.secret, sent)); + } + try { + const res = await fetch(hook.url, {method: 'POST', headers, body: sent, signal: AbortSignal.timeout(10_000)}); + return {hookId: hook.id, url: hook.url, status: res.status, body: await res.text()}; + } catch (err) { + return {hookId: hook.id, url: hook.url, status: 0, body: String(err)}; + } + })); +} + +export function signatures(secret: string, body: string) { + return { + 'X-Hub-Signature': `sha1=${crypto.createHmac('sha1', secret).update(body).digest('hex')}`, + 'X-Hub-Signature-256': `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`, + }; +} + +function repositoryWebhook(state: State, repo: Repo) { + return merge(skeleton('repository-webhooks'), state.repoJSON(repo, null)); +} + +function issueWebhook(state: State, item: Item) { + const issue = state.issueJSON(item); + return {...issue, reactions: merge(skeleton('reaction-rollup'), {url: `${issue.url}/reactions`})}; +} + +function push(state: State, repo: Repo, pusher: string, ref: string) { + const name = fullName(repo); + const after = sha({repo: name, number: Date.now()} as Item, 'head'); + const before = sha({repo: name, number: Date.now() - 1} as Item, 'base'); + const account = state.login(pusher); + const person = {name: account.name ?? account.login, email: account.email ?? `${account.login}@users.noreply.github.com`, username: account.login}; + const commit = { + id: after, tree_id: after, distinct: true, message: 'Update README', timestamp: state.now(), url: `${WEB}/${name}/commit/${after}`, + author: person, committer: person, added: [], removed: [], modified: ['README.md'], + }; + return { + ref, before, after, created: false, deleted: false, forced: false, base_ref: null, compare: `${WEB}/${name}/compare/${before.slice(0, 12)}...${after.slice(0, 12)}`, + commits: [commit], head_commit: commit, pusher: {name: account.login, email: person.email}, + repository: {...repositoryWebhook(state, repo), url: `${WEB}/${name}`, created_at: Math.floor(Date.parse(repo.createdAt) / 1000), pushed_at: Math.floor(Date.now() / 1000), stargazers: 0, master_branch: 'main'}, + }; +} diff --git a/e2e/integration/global_setup.ts b/e2e/integration/global_setup.ts new file mode 100644 index 000000000..d1b9d326c --- /dev/null +++ b/e2e/integration/global_setup.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {baseGlobalSetup} from '@mattermost/playwright-lib'; + +import {startFakeGitHub} from './fake-github/server'; +import {loginAdmin} from './support/admin'; +import {fakeGitHubPort, fakeGitHubURL, oauthApp} from './support/constants'; +import {installPlugin} from './support/plugin'; + +export default async function globalSetup() { + await baseGlobalSetup(); + + const fake = await startFakeGitHub({port: fakeGitHubPort, publicURL: fakeGitHubURL, app: oauthApp}); + + const adminClient = await loginAdmin(); + await installPlugin(adminClient); + + return fake.close; +} diff --git a/e2e/integration/package-lock.json b/e2e/integration/package-lock.json new file mode 100644 index 000000000..7d8659bcd --- /dev/null +++ b/e2e/integration/package-lock.json @@ -0,0 +1,1954 @@ +{ + "name": "mattermost-plugin-github-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mattermost-plugin-github-e2e", + "devDependencies": { + "@mattermost/client": "11.3.0", + "@mattermost/playwright-lib": "11.4.0", + "@mattermost/types": "11.3.0", + "@playwright/test": "1.64.1", + "@types/node": "22.20.5", + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "cross-env": "7.0.3", + "graphql": "16.14.2", + "typescript": "5.9.3" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", + "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@mattermost/client": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@mattermost/client/-/client-11.3.0.tgz", + "integrity": "sha512-ulDcKXRcmxwqsqqRJemyq47UlpSVnPNKQrg/FHfEEzgLdhTlnrdMnRGZUssuCeZ9DXRkSw+uZwudiJoJ0bc47A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@mattermost/types": "11.3.0", + "typescript": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@mattermost/playwright-lib": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/@mattermost/playwright-lib/-/playwright-lib-11.4.0.tgz", + "integrity": "sha512-rzKROUwzDK3qMoV4wXZBW3RfHePmH0GhhqYNJtt74qT1JMKSqpLbSFYtcrRsF9MgPQRc5ZxwWHSKerj31Zx9qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@axe-core/playwright": "4.11.0", + "@mattermost/client": "11.3.0", + "@mattermost/types": "11.3.0", + "@percy/cli": "1.31.8", + "@percy/playwright": "1.0.10", + "async-wait-until": "2.0.31", + "axe-core": "4.11.1", + "chalk": "4.1.2", + "deepmerge": "4.3.1", + "dotenv": "17.2.3", + "luxon": "3.7.2", + "mime-types": "3.0.2", + "uuid": "13.0.0" + }, + "peerDependencies": { + "@playwright/test": ">=1.55.0" + } + }, + "node_modules/@mattermost/types": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-11.3.0.tgz", + "integrity": "sha512-ZjkF1D28Jn+7Agki2de1FIFIWx7LbdqqIaaZ5JgOGpqtkkr9PQ0AW+WLOoEzokWEZ54dXB9INSuGLEczTfrt8w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@percy/cli": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.31.8.tgz", + "integrity": "sha512-soyU/AgK3dkKIv1tNKIaU4oi+JBroWhpvm29LlQMeLk87cItkt/Lp3aTs3HHXUKZqYGuhLQlci11n2uLtUvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-app": "1.31.8", + "@percy/cli-build": "1.31.8", + "@percy/cli-command": "1.31.8", + "@percy/cli-config": "1.31.8", + "@percy/cli-exec": "1.31.8", + "@percy/cli-snapshot": "1.31.8", + "@percy/cli-upload": "1.31.8", + "@percy/client": "1.31.8", + "@percy/logger": "1.31.8" + }, + "bin": { + "percy": "bin/run.cjs" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-app": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.31.8.tgz", + "integrity": "sha512-kbSv+ZVf/fpk5R8ewLb8LwHftTOHT2XJ6MNU9s81sKEv9iDqsf6vIiDF+/nKMSsMX+ns1evw+4I2vEjASmDzoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8", + "@percy/cli-exec": "1.31.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-build": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.31.8.tgz", + "integrity": "sha512-aPZV6Lv7BRbGFqdGGPgfV8mMjgnoYQQQ7E1GFyVbZvsOrmqOif3osrmvwSwO7kd730cmKfhzg2iHbVBzEg+z4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-command": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.31.8.tgz", + "integrity": "sha512-nANwIfbLS78OJ9LV7WE4A6Tp1bN9OLpIPsZR7RgfNxG8YZFkyprbDA2zV+p6IYcfKoSnJBBD9isIUL+ITK+cgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.31.8", + "@percy/core": "1.31.8", + "@percy/logger": "1.31.8" + }, + "bin": { + "percy-cli-readme": "bin/readme.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-config": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.31.8.tgz", + "integrity": "sha512-VmJkkwUkckbQVXecgQAewHR1lMWh/t8mEbVR9W/8iX/H1hOg1ONADFxNC/qfi3PhOO5kdWktS4r51jc0BgIEDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-exec": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.31.8.tgz", + "integrity": "sha512-SRZNwIZCyh38OtGWwPcQuwMVxeViHy4kP/Y0QJleDaOdH34MITwQuNMquf4Q7vZmEJ1mCamZpy/WUCDJICTuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8", + "@percy/logger": "1.31.8", + "cross-spawn": "^7.0.3", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-snapshot": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.31.8.tgz", + "integrity": "sha512-RQNx7eUq7Xml/EtQjsYvb9nWP/Wk4vf5SqAYHjAXSDDiK1K4BHdcxT7+GO1F+qq720/KgjwnC/JmBTulpC5a5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-upload": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.31.8.tgz", + "integrity": "sha512-yuegEGVcxd/PneheBfd0D9HE36EkdPS6u3m9m6Y8GPkN0UN7eOVxoOqfPHT9wCRfUjR3K9qcdBvo11S6biLWTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.31.8", + "fast-glob": "^3.2.11", + "image-size": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/client": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.31.8.tgz", + "integrity": "sha512-MxhOY5DWJnW3dsmzYICgYOyKCFLwJcaC19jTsonfmQHBa8/cKs4mUKnrUgvtJlOsW6mSk6WrAn8vUUwZ4438xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.31.8", + "@percy/env": "1.31.8", + "@percy/logger": "1.31.8", + "pac-proxy-agent": "^7.0.2", + "pako": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/config": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.31.8.tgz", + "integrity": "sha512-iK7n0YOlmk4ITvQ8l4BeaInrS/cE8MqD+368cAEGVhPjGjarDKZ06FvEZXoxU8J+4LZbNyN9h/o+2UDtFRPYyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/logger": "1.31.8", + "ajv": "^8.6.2", + "cosmiconfig": "^8.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/core": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.31.8.tgz", + "integrity": "sha512-p5KWNvbU8GUlA/DxV8q9N0alSzaEKx8aJxgO8TkmMA3HalMl/HogYw5+B7tvOUU0CAw9ar5vXyPXuOIm4wpqvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@percy/client": "1.31.8", + "@percy/config": "1.31.8", + "@percy/dom": "1.31.8", + "@percy/logger": "1.31.8", + "@percy/monitoring": "1.31.8", + "@percy/webdriver-utils": "1.31.8", + "content-disposition": "^0.5.4", + "cross-spawn": "^7.0.3", + "extract-zip": "^2.0.1", + "fast-glob": "^3.2.11", + "micromatch": "^4.0.8", + "mime-types": "^2.1.34", + "pako": "^2.1.0", + "path-to-regexp": "^6.3.0", + "rimraf": "^3.0.2", + "ws": "^8.17.1", + "yaml": "^2.4.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/core/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@percy/core/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@percy/dom": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.31.8.tgz", + "integrity": "sha512-uw4aOTTXWgj3dZPrDlM+j0+MzwDz9u8EfE92fi2HsyqDXgp0hw957b3F/YEE9TMR27s9OgwXKnOCd8zauoj0Ew==", + "dev": true, + "license": "MIT" + }, + "node_modules/@percy/env": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.31.8.tgz", + "integrity": "sha512-nIwSkwIijMCxvRZE2MV1rn51NGHM3EgRayuKxgkhevzc1s4Y785GTS8CZyJaPshs1fWVBork87Tm9Tbuh3u1PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/logger": "1.31.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/logger": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.31.8.tgz", + "integrity": "sha512-OqnrtfmCdKW+2Z8LYk8Jc9HK0P89TJqp2qWnM913eDeoYZOKJX+2IyAZfsaFTcfEBAqHn0v1eynfldRedbTk3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/monitoring": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/monitoring/-/monitoring-1.31.8.tgz", + "integrity": "sha512-NBLQIoXWZb1Oi8M6Q6o8rmA6PzZi+65cxgyoKXHboxF7wAb8i8Vyc2JM7zTvay6RZ6JGuVHpqkxPcQaarxM1IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.31.8", + "@percy/logger": "1.31.8", + "@percy/sdk-utils": "1.31.8", + "systeminformation": "^5.25.11" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/playwright": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@percy/playwright/-/playwright-1.0.10.tgz", + "integrity": "sha512-lq2Mbqz/SfguQn4PdbNwApmzZpA/3gWO7STLlyLNYd0r4btGd7Nfxyxkf/t78rgh2ErwGcLUuPbxGPpZ3XXLVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "playwright-core": ">=1" + } + }, + "node_modules/@percy/sdk-utils": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.8.tgz", + "integrity": "sha512-S+qxi4TIOvToAD5j89nkdDj0Xj5CH8YJxpI6ZRVJE/UQE+amHIP34KiTdrWKw5aPlYEwNPeNn9UlXz5HUr5Z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/webdriver-utils": { + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/webdriver-utils/-/webdriver-utils-1.31.8.tgz", + "integrity": "sha512-mfKR5EaXTTTLXX2JBFKmz9OphhHHBIMwe6lkI/hVgl1jvnTV2FXSSS2xb2Sume86uGFyj53NRH+HTNf/tepQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@percy/config": "1.31.8", + "@percy/sdk-utils": "1.31.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-wait-until": { + "version": "2.0.31", + "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.31.tgz", + "integrity": "sha512-9VCfHvc4f36oT6sG5p16aKc9zojf3wF4FrjNDxU3Db51SJ1bQ5lWAWtQDDZPysTwSLKBDzNZ083qPkTIj6XnrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.14.0", + "npm": ">= 1.0.0" + }, + "funding": { + "type": "individual", + "url": "http://paypal.me/devlatoau" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/systeminformation": { + "version": "5.33.19", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.19.tgz", + "integrity": "sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/e2e/integration/package.json b/e2e/integration/package.json new file mode 100644 index 000000000..2b855a350 --- /dev/null +++ b/e2e/integration/package.json @@ -0,0 +1,30 @@ +{ + "name": "mattermost-plugin-github-e2e", + "private": true, + "scripts": { + "env:up": "docker compose up -d --wait", + "env:down": "docker compose --profile visual down -v", + "test": "playwright test", + "test:ci": "cross-env CI=true playwright test --grep-invert @visual", + "test:visual": "./scripts/visual.sh", + "test:visual-update-snapshots": "./scripts/visual.sh --update-snapshots", + "test:headed": "cross-env PW_HEADLESS=false playwright test", + "show-report": "playwright show-report", + "tsc": "tsc --noEmit", + "contract:update": "node fake-github/contract/update_specs.mjs", + "contract:check": "node fake-github/contract/update_specs.mjs --check", + "test:real": "playwright test -c playwright.real.config.ts" + }, + "devDependencies": { + "@mattermost/client": "11.3.0", + "@mattermost/playwright-lib": "11.4.0", + "@mattermost/types": "11.3.0", + "@playwright/test": "1.64.1", + "@types/node": "22.20.5", + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "cross-env": "7.0.3", + "graphql": "16.14.2", + "typescript": "5.9.3" + } +} diff --git a/e2e/integration/playwright.config.ts b/e2e/integration/playwright.config.ts new file mode 100644 index 000000000..0962f83f2 --- /dev/null +++ b/e2e/integration/playwright.config.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import {defineConfig, devices} from '@playwright/test'; + +import {fakeGitHubHost, mattermostURL} from './support/constants'; +import type {FailureScreenshotOptions} from './support/fixture'; + +const isCI = Boolean(process.env.CI); +const visualBrowser = process.env.PW_VISUAL_BROWSER_WS; + +export default defineConfig({ + testDir: './tests', + outputDir: './test-results', + + // No platform suffix: baselines always come from the dockerized browser. + snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + globalSetup: require.resolve('./global_setup'), + fullyParallel: false, + workers: 1, + forbidOnly: isCI, + retries: isCI ? 1 : 0, + timeout: 60_000, + expect: {timeout: 15_000, toHaveScreenshot: {stylePath: './support/visual.css'}}, + reporter: isCI ? [['github'], ['html', {open: 'never', outputFolder: 'playwright-report'}]] : [['list'], ['html', {open: 'never'}]], + use: { + baseURL: mattermostURL, + headless: process.env.PW_HEADLESS !== 'false', + // Only fake-github tokens and a throwaway server's sessions end up in these, so CI keeps them too. + trace: 'retain-on-failure', + screenshot: 'off', + failureScreenshots: true, + + // The dockerized visual browser shares Mattermost's network and /etc/hosts, where fake-github already resolves. + launchOptions: { + slowMo: Number(process.env.PW_SLOWMO ?? 0), + args: visualBrowser ? [] : [`--host-resolver-rules=MAP ${fakeGitHubHost} 127.0.0.1`], + }, + connectOptions: visualBrowser ? {wsEndpoint: visualBrowser} : undefined, + }, + projects: [ + { + name: 'chrome', + use: {...devices['Desktop Chrome'], viewport: {width: 1280, height: 1024}}, + }, + ], +}); diff --git a/e2e/integration/playwright.real.config.ts b/e2e/integration/playwright.real.config.ts new file mode 100644 index 000000000..468004e4a --- /dev/null +++ b/e2e/integration/playwright.real.config.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import {defineConfig, devices} from '@playwright/test'; + +import {mattermostURL} from './support/constants'; + +const isCI = Boolean(process.env.CI); + +// Journey against real github.com. Needs a normal `make dist` build, not `make e2e-dist`. +export default defineConfig({ + testDir: './real-github', + testMatch: /journey\.spec\.ts$/, + outputDir: './test-results/real-github', + globalSetup: require.resolve('./real-github/global_setup'), + fullyParallel: false, + workers: 1, + forbidOnly: isCI, + + // Retries would repeat GitHub writes. + retries: 0, + timeout: 5 * 60_000, + expect: {timeout: 30_000}, + reporter: isCI ? [['github'], ['list']] : [['list'], ['html', {open: 'never', outputFolder: 'playwright-report/real-github'}]], + use: { + baseURL: mattermostURL, + headless: isCI || process.env.PW_HEADLESS !== 'false', + + // Real tokens, GitHub cookies and the login password end up in traces and screenshots, so they stay off in CI. + trace: isCI ? 'off' : 'retain-on-failure', + screenshot: 'off', + video: 'off', + }, + projects: [ + { + name: 'real-github', + use: {...devices['Desktop Chrome'], viewport: {width: 1280, height: 1024}}, + }, + ], +}); diff --git a/e2e/integration/real-github/api.ts b/e2e/integration/real-github/api.ts new file mode 100644 index 000000000..6c8de42c9 --- /dev/null +++ b/e2e/integration/real-github/api.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; + +import {realGitHub} from './env'; +import {reserveWrite} from './rate_limit'; + +export type GitHubResponse = {status: number; body: T; headers: Headers}; + +export async function gh(method: string, path: string, {body, token = realGitHub.machineToken, auth}: {body?: unknown; token?: string; auth?: string} = {}): Promise> { + if (method !== 'GET') { + await reserveWrite(); + } + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${realGitHub.apiURL}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: auth ?? `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'mattermost-plugin-github-e2e', + ...(body === undefined ? {} : {'Content-Type': 'application/json'}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + // Secondary limits answer 403 or 429 with retry-after; primary ones send x-ratelimit-reset. + const retryAfter = Number(res.headers.get('retry-after') ?? 0); + if ((res.status === 429 || (res.status === 403 && (retryAfter || res.headers.get('x-ratelimit-remaining') === '0'))) && attempt < 2) { + const resetMs = Number(res.headers.get('x-ratelimit-reset') ?? 0) * 1000 - Date.now(); + const waitMs = retryAfter ? retryAfter * 1000 : resetMs; + if (waitMs > 0 && waitMs <= 2 * 60_000) { + await new Promise((r) => setTimeout(r, waitMs)); + continue; + } + } + const text = await res.text(); + return {status: res.status, body: (text ? JSON.parse(text) : undefined) as T, headers: res.headers}; + } +} + +export async function ghOK(method: string, path: string, options?: Parameters[2]): Promise { + const res = await gh(method, path, options); + if (res.status >= 300) { + throw new Error(`${method} ${path} answered ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`); + } + return res.body; +} + +// The search index trails writes by seconds to minutes. +export async function waitForSearch(query: string, matches: (item: {number: number; title: string}) => boolean, timeoutMs = 3 * 60_000) { + await expect.poll(async () => { + const res = await gh<{items: Array<{number: number; title: string}>}>('GET', `/search/issues?q=${encodeURIComponent(query)}&per_page=50`); + return res.status === 200 && res.body.items.some(matches); + }, {message: `GitHub search never returned a match for "${query}"`, timeout: timeoutMs, intervals: [5_000, 10_000, 15_000]}).toBe(true); +} diff --git a/e2e/integration/real-github/env.ts b/e2e/integration/real-github/env.ts new file mode 100644 index 000000000..fbe75e513 --- /dev/null +++ b/e2e/integration/real-github/env.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import path from 'node:path'; + +import '../support/constants'; + +const required = [ + 'E2E_GITHUB_OAUTH_CLIENT_ID', + 'E2E_GITHUB_OAUTH_CLIENT_SECRET', + 'E2E_GITHUB_REPO', + 'E2E_GITHUB_MACHINE_TOKEN', +] as const; + +export function missingRealGitHubEnv() { + return required.filter((name) => !process.env[name]); +} + +const env = (name: (typeof required)[number]) => process.env[name] ?? ''; + +const [org, repo] = env('E2E_GITHUB_REPO').split('/'); + +export const realGitHub = { + apiURL: 'https://api.github.com', + webURL: 'https://github.com', + org, + repo, + fullName: `${org}/${repo}`, + oauth: {clientId: env('E2E_GITHUB_OAUTH_CLIENT_ID'), clientSecret: env('E2E_GITHUB_OAUTH_CLIENT_SECRET')}, + machineToken: env('E2E_GITHUB_MACHINE_TOKEN'), + + // Optional: the real-login test skips without them. + login: { + username: process.env.E2E_GITHUB_LOGIN_USERNAME || undefined, + password: process.env.E2E_GITHUB_LOGIN_PASSWORD || undefined, + totpSecret: process.env.E2E_GITHUB_LOGIN_TOTP_SECRET || undefined, + }, + + // Shared between global setup and the tests, which run in separate processes. + statePath: path.join(__dirname, '../.auth/real-github-state.json'), + writeLogPath: path.join(__dirname, '../.auth/real-github-writes.json'), +}; + +export const canSignInWithPassword = () => Boolean(realGitHub.login.username && realGitHub.login.password && realGitHub.login.totpSecret); diff --git a/e2e/integration/real-github/fixture.ts b/e2e/integration/real-github/fixture.ts new file mode 100644 index 000000000..bf194d49d --- /dev/null +++ b/e2e/integration/real-github/fixture.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Channel} from '@mattermost/types/channels'; +import type {Team} from '@mattermost/types/teams'; +import type {UserProfile} from '@mattermost/types/users'; +import {expect, test as base} from '@mattermost/playwright-lib'; + +import {seedUserInfo} from '../support/kv'; + +import {realGitHub} from './env'; +import {readState, type RealState} from './fixtures'; + +export {expect}; + +export type RealGitHubFixture = { + adminClient: Client4; + user: UserProfile; + userClient: Client4; + team: Team; + channel: Channel; + state: RealState; + connectMachine: () => void; +}; + +// pw.initSetup() is deliberately avoided: it resets server config, which drops the plugin's settings. +export const test = base.extend<{real: RealGitHubFixture}>({ + real: async ({pw}, use) => { + const {adminClient} = await pw.getAdminClient(); + const team = await pw.createNewTeam(adminClient, {name: 'github', displayName: 'GitHub', type: 'O', unique: true}); + const user = await pw.createNewUserProfile(adminClient, {prefix: 'github', disableTutorial: true, disableOnboarding: true}); + await adminClient.addToTeam(team.id, user.id); + const {client: userClient} = await pw.makeClient(user); + if (!userClient) { + throw new Error(`Failed to log in ${user.username}`); + } + const channel = await adminClient.getChannelByName(team.id, 'town-square'); + const state = readState(); + + const connectMachine = () => seedUserInfo(user.id, state.login, realGitHub.machineToken, {allowedPrivateRepos: true}); + + await use({adminClient, user, userClient, team, channel, state, connectMachine}); + }, +}); diff --git a/e2e/integration/real-github/fixtures.ts b/e2e/integration/real-github/fixtures.ts new file mode 100644 index 000000000..2411b6d4b --- /dev/null +++ b/e2e/integration/real-github/fixtures.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {randomBytes} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import {gh, ghOK, waitForSearch} from './api'; +import {realGitHub} from './env'; + +export type RealState = { + login: string; + issue: {number: number; title: string}; + pull: {number: number; title: string}; + hookId: number; + webhookSecret: string; +}; + +export const fixtureLabel = 'e2e-fixture'; +const fixtureBranch = 'e2e-fixture'; +const issueTitle = 'E2E fixture: assigned issue'; +const pullTitle = 'E2E fixture: open pull request'; + +// GitHub refuses hooks to private addresses, so this one never delivers; the tests replay its recorded deliveries instead. +export const fixtureHookURL = 'https://example.com/mattermost-plugin-github-e2e'; + +export function readState(): RealState { + return JSON.parse(fs.readFileSync(realGitHub.statePath, 'utf8')); +} + +// Long-lived fixtures are created once and reused, so a nightly run costs a handful of writes. +export async function frontLoad(): Promise { + const {fullName} = realGitHub; + const {login} = await ghOK<{login: string}>('GET', '/user'); + + if ((await gh('GET', `/repos/${fullName}/labels/${fixtureLabel}`)).status === 404) { + await ghOK('POST', `/repos/${fullName}/labels`, {body: {name: fixtureLabel, color: 'ededed', description: 'Kept open for the Mattermost plugin E2E suite'}}); + } + + const issue = await ensureIssue(login); + const pull = await ensurePull(login); + + await waitForSearch(`repo:${fullName} is:issue is:open assignee:${login} label:${fixtureLabel}`, (i) => i.number === issue.number); + await waitForSearch(`repo:${fullName} is:pr is:open author:${login} label:${fixtureLabel}`, (i) => i.number === pull.number); + + const hookId = await ensureHook(); + const webhookSecret = randomBytes(20).toString('hex'); + await ghOK('PATCH', `/repos/${fullName}/hooks/${hookId}/config`, {body: {url: fixtureHookURL, content_type: 'json', secret: webhookSecret}}); + const state: RealState = {login, issue, pull, hookId, webhookSecret}; + + fs.mkdirSync(path.dirname(realGitHub.statePath), {recursive: true}); + fs.writeFileSync(realGitHub.statePath, JSON.stringify(state)); + return state; +} + +async function ensureIssue(login: string) { + const open = await ghOK>('GET', `/repos/${realGitHub.fullName}/issues?labels=${fixtureLabel}&state=open&per_page=100`); + const found = open.find((i) => !i.pull_request && i.title === issueTitle); + if (found) { + return {number: found.number, title: found.title}; + } + const created = await ghOK('POST', `/repos/${realGitHub.fullName}/issues`, {body: {title: issueTitle, body: 'Kept open for the E2E suite.', labels: [fixtureLabel], assignees: [login]}}); + return {number: created.number, title: issueTitle}; +} + +async function ensurePull(login: string) { + const {fullName, org} = realGitHub; + const open = await ghOK>('GET', `/repos/${fullName}/pulls?state=open&head=${org}:${fixtureBranch}`); + if (open.length) { + return {number: open[0].number, title: pullTitle}; + } + + const {default_branch: base} = await ghOK('GET', `/repos/${fullName}`); + if ((await gh('GET', `/repos/${fullName}/git/ref/heads/${fixtureBranch}`)).status === 404) { + const {object} = await ghOK('GET', `/repos/${fullName}/git/ref/heads/${base}`); + await ghOK('POST', `/repos/${fullName}/git/refs`, {body: {ref: `refs/heads/${fixtureBranch}`, sha: object.sha}}); + } + if ((await gh('GET', `/repos/${fullName}/contents/e2e-fixture.md?ref=${fixtureBranch}`)).status === 404) { + await ghOK('PUT', `/repos/${fullName}/contents/e2e-fixture.md`, {body: {message: 'Add E2E fixture file', content: Buffer.from('Kept open for the E2E suite.\n').toString('base64'), branch: fixtureBranch}}); + } + const created = await ghOK('POST', `/repos/${fullName}/pulls`, {body: {title: pullTitle, head: fixtureBranch, base, body: 'Kept open for the E2E suite.'}}); + await ghOK('POST', `/repos/${fullName}/issues/${created.number}/labels`, {body: {labels: [fixtureLabel]}}); + if (created.user.login !== login) { + throw new Error('The fixture pull request must be authored by the machine account'); + } + return {number: created.number, title: pullTitle}; +} + +async function ensureHook() { + const hooks = await ghOK>('GET', `/repos/${realGitHub.fullName}/hooks?per_page=100`); + const found = hooks.find((h) => h.config.url === fixtureHookURL); + if (found) { + return found.id; + } + const created = await ghOK('POST', `/repos/${realGitHub.fullName}/hooks`, { + body: {name: 'web', active: true, events: ['issues', 'issue_comment', 'pull_request'], config: {url: fixtureHookURL, content_type: 'json'}}, + }); + return created.id as number; +} diff --git a/e2e/integration/real-github/global_setup.ts b/e2e/integration/real-github/global_setup.ts new file mode 100644 index 000000000..d6eb5c5d3 --- /dev/null +++ b/e2e/integration/real-github/global_setup.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {baseGlobalSetup} from '@mattermost/playwright-lib'; + +import {loginAdmin} from '../support/admin'; +import {installPlugin} from '../support/plugin'; + +import {missingRealGitHubEnv, realGitHub} from './env'; +import {frontLoad} from './fixtures'; + +export default async function globalSetup() { + const missing = missingRealGitHubEnv(); + if (missing.length) { + throw new Error(`Set ${missing.join(', ')} in e2e/integration/.env (see .env.example). Do not paste the values into chat.`); + } + const state = await frontLoad(); + await baseGlobalSetup(); + + const adminClient = await loginAdmin(); + await installPlugin(adminClient, { + githuboauthclientid: realGitHub.oauth.clientId, + githuboauthclientsecret: realGitHub.oauth.clientSecret, + webhooksecret: state.webhookSecret, + githuborg: realGitHub.org, + enableprivaterepo: true, + }); +} diff --git a/e2e/integration/real-github/journey.spec.ts b/e2e/integration/real-github/journey.spec.ts new file mode 100644 index 000000000..1e5c5ecf9 --- /dev/null +++ b/e2e/integration/real-github/journey.spec.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {validateGraphQLQuery, validateResponse, validateWebhook} from '../fake-github/contract/validator'; +import {signatures} from '../fake-github/webhooks'; +import {mattermostURL, webhookURL} from '../support/constants'; +import {readUserInfo} from '../support/kv'; +import {channelPosts, chooseOption, openChannel, openCreateIssueModal, runSlashCommand, selectField, sidebarButton, waitForPost} from '../support/ui'; + +import {gh, ghOK} from './api'; +import {canSignInWithPassword, realGitHub} from './env'; +import {expect, test, type RealGitHubFixture} from './fixture'; +import {readState} from './fixtures'; +import {completeGitHubSignIn} from './login/github_login'; +import {reserveWrite} from './rate_limit'; + +const {fullName} = realGitHub; + +type Delivery = {id: number; event: string; action: string | null; delivered_at: string}; +type DeliveryDetail = Delivery & {request: {headers: Record; payload: Record}}; + +// GitHub can't reach this Mattermost, so the hook's recorded delivery is fetched and posted to the plugin. +async function replayDelivery(real: RealGitHubFixture, event: string, match: (payload: Record) => boolean) { + const path = `/repos/${fullName}/hooks/${real.state.hookId}/deliveries`; + let detail: DeliveryDetail | undefined; + await expect.poll(async () => { + const recent = await ghOK('GET', `${path}?per_page=20`); + for (const d of recent.filter((r) => r.event === event)) { + const candidate = await ghOK('GET', `${path}/${d.id}`); + if (match(candidate.request.payload)) { + detail = candidate; + return true; + } + } + return false; + }, {message: `no ${event} delivery recorded`, timeout: 2 * 60_000, intervals: [5_000]}).toBe(true); + + const {payload, headers} = detail!.request; + + // The API hands back the parsed payload, not the bytes GitHub signed, so it is re-signed with the hook's secret. + const body = JSON.stringify(payload); + const res = await fetch(webhookURL, { + method: 'POST', + body, + headers: {'Content-Type': 'application/json', 'X-GitHub-Event': event, 'X-GitHub-Delivery': headers['X-GitHub-Delivery'] ?? '', ...signatures(real.state.webhookSecret, body)}, + }); + return {status: res.status, payload}; +} + +test.describe.serial('real GitHub with a KV-seeded machine token', () => { + test('GitHub still behaves the way fake-github models it', async () => { + const hooks = await gh('GET', `/repos/${fullName}/hooks`); + const accepted = (hooks.headers.get('x-accepted-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean); + expect.soft(accepted, 'fake-github answers repo hooks with 404 unless the token has one of these scopes').toEqual(expect.arrayContaining(['admin:repo_hook', 'read:repo_hook', 'repo', 'write:repo_hook'])); + expect.soft(accepted).not.toContain('public_repo'); + + const query = `{search(type: ISSUE, first: 1, query: "repo:${fullName} label:e2e-fixture") {nodes {... on Issue {state} ... on PullRequest {state}}}}`; + expect(validateGraphQLQuery(query).errors, 'the validator lets this shape conflict through').toEqual([]); + const graphql = await ghOK<{data?: unknown; errors?: unknown[]}>('POST', '/graphql', {body: {query}}); + expect.soft(graphql.errors, 'GitHub answers overlapping Issue/PullRequest state fields').toBeUndefined(); + + const token = await fetch(`${realGitHub.webURL}/login/oauth/access_token`, { + method: 'POST', + headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, + body: JSON.stringify({client_id: realGitHub.oauth.clientId, client_secret: realGitHub.oauth.clientSecret, code: 'not-a-real-code'}), + }); + expect.soft(token.status, 'token errors come back as HTTP 200').toBe(200); + expect.soft(await token.json()).toMatchObject({error: 'bad_verification_code'}); + + const {issue, pull} = readState(); + for (const path of ['/user', `/repos/${fullName}`, `/repos/${fullName}/issues/${issue.number}`, `/repos/${fullName}/pulls/${pull.number}`, `/repos/${fullName}/labels`]) { + expect.soft(validateResponse('GET', path, 200, await ghOK('GET', path)), `real ${path} matches the committed spec`).toEqual([]); + } + }); + + test('the sidebar and tooltip show the front-loaded issue and pull request', async ({pw, real}) => { + real.connectMachine(); + const {page, channelsPage} = await openChannel(pw, real.user, real.team.name); + + await runSlashCommand(channelsPage, '/github me'); + await expect(page.getByText(real.state.login).last()).toBeVisible(); + + const rhs = page.locator('#rhsContainer'); + await sidebarButton(page, 'fa-compress').click(); + await expect(rhs.getByRole('link', {name: real.state.pull.title})).toBeVisible(); + await sidebarButton(page, 'fa-list-ol').click(); + await expect(rhs.getByRole('link', {name: real.state.issue.title})).toBeVisible(); + + const url = `${realGitHub.webURL}/${fullName}/issues/${real.state.issue.number}`; + await real.userClient.createPost({channel_id: real.channel.id, message: url} as any); + await page.getByRole('link', {name: url}).last().hover(); + await expect(page.locator('.github-tooltip .box').first()).toContainText(real.state.issue.title); + }); + + test('an issue created from a post reaches GitHub, and its webhook posts back to the channel', async ({pw, real}) => { + real.connectMachine(); + const title = `E2E journey ${Date.now()}`; + await real.userClient.executeCommand(`/github subscriptions add ${fullName} --features issues`, {channel_id: real.channel.id, team_id: real.team.id}); + await real.userClient.createPost({channel_id: real.channel.id, message: 'File this from Mattermost'} as any); + const {channelsPage, page} = await openChannel(pw, real.user, real.team.name); + + const modal = await openCreateIssueModal(channelsPage, page); + await expect(selectField(modal, 'Organization')).toContainText(realGitHub.org); + await chooseOption(page, selectField(modal, 'Repository'), realGitHub.repo); + await modal.getByLabel('Title for the GitHub Issue').fill(title); + await reserveWrite(); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + + const reply = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); + const number = Number(/\/issues\/(\d+)/.exec(reply.message)?.[1]); + try { + expect(await ghOK('GET', `/repos/${fullName}/issues/${number}`)).toMatchObject({title, user: {login: real.state.login}}); + + const {status, payload} = await replayDelivery(real, 'issues', (p) => p.action === 'opened' && p.issue?.number === number); + expect.soft(validateWebhook('issues', payload), 'the real payload matches the schema fake-github validates against').toEqual([]); + expect(status).toBe(200); + const post = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.includes('#new-issue') && p.message.includes(title), 'issues.opened post'); + expect(post.message).toContain(`/${fullName}/issues/${number}`); + } finally { + await ghOK('PATCH', `/repos/${fullName}/issues/${number}`, {body: {state: 'closed', state_reason: 'not_planned'}}); + } + }); +}); + +test.describe('real GitHub sign-in', () => { + test.skip(!canSignInWithPassword(), 'needs E2E_GITHUB_LOGIN_USERNAME, _PASSWORD and _TOTP_SECRET'); + + test('a user connects through github.com with password and TOTP', async ({pw, real}) => { + const {page} = await openChannel(pw, real.user, real.team.name); + const complete = `${mattermostURL}/plugins/github/oauth/complete`; + + await page.goto(`${mattermostURL}/plugins/github/oauth/connect`); + await completeGitHubSignIn(page, () => page.url().startsWith(complete)); + await expect(page.getByText(/Completed connecting to GitHub/i)).toBeVisible(); + + const info = readUserInfo(real.user.id); + expect(info?.GitHubUsername.toLowerCase()).toBe(realGitHub.login.username!.toLowerCase()); + const token = info!.Token.access_token; + try { + const me = await gh('GET', '/user', {token}); + const scopes = (me.headers.get('x-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean).sort(); + expect(scopes, 'the plugin asks for these scopes without /github connect private').toEqual(['admin:org_hook', 'notifications', 'public_repo', 'read:org']); + + const hooks = await gh('GET', `/repos/${fullName}/hooks`, {token}); + expect.soft(hooks.status, 'fake-github hides repo hooks from public_repo tokens, so /github subscriptions add always notes a missing webhook').toBe(404); + } finally { + // Revoking the grant brings back the consent screen and drops any widened scopes before the next run. + const basic = `Basic ${Buffer.from(`${realGitHub.oauth.clientId}:${realGitHub.oauth.clientSecret}`).toString('base64')}`; + await gh('DELETE', `/applications/${realGitHub.oauth.clientId}/grant`, {auth: basic, body: {access_token: token}}); + } + }); +}); diff --git a/e2e/integration/real-github/login/github_login.ts b/e2e/integration/real-github/login/github_login.ts new file mode 100644 index 000000000..1a49cc2c2 --- /dev/null +++ b/e2e/integration/real-github/login/github_login.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Locator, Page} from '@playwright/test'; + +import {realGitHub} from '../env'; + +import {generateTotp} from './totp'; + +// Walks github.com sign-in, 2FA and OAuth consent until isDone. GitHub skips the emailed device check for 2FA accounts. +export async function completeGitHubSignIn(page: Page, isDone: () => boolean, timeoutMs = 2 * 60_000) { + const {username, password, totpSecret} = realGitHub.login; + let lastCode = ''; + + const steps: Array<[Locator, (el: Locator) => Promise]> = [ + [page.locator('input#login_field'), async (el) => { + await el.fill(username!); + await page.locator('input#password').fill(password!); + await page.locator('input[name="commit"]').click(); + }], + [page.locator('a[href="/sessions/two-factor/app"]'), (el) => el.click()], + [page.locator('input#app_totp'), async (el) => { + let code = generateTotp(totpSecret!); + while (code === lastCode) { + await page.waitForTimeout(2000); + code = generateTotp(totpSecret!); + } + lastCode = code; + + // The field submits itself once six digits are in. + await el.fill(code); + }], + [page.getByRole('button', {name: /^(remind me later|not now|skip)/i}).first(), (el) => el.click()], + [page.locator('button#js-oauth-authorize-btn:not([disabled])'), (el) => el.click()], + ]; + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (isDone()) { + return; + } + if (new URL(page.url()).pathname.startsWith('/sessions/verified-device')) { + throw new Error('GitHub asked for an emailed device code; the login account needs 2FA with an authenticator app'); + } + for (const [el, act] of steps) { + if (await el.isVisible().catch(() => false)) { + await act(el); + await page.waitForLoadState('domcontentloaded').catch(() => undefined); + break; + } + } + await page.waitForTimeout(1000); + } + throw new Error(`GitHub sign-in did not finish; last page was ${new URL(page.url()).pathname}`); +} diff --git a/e2e/integration/real-github/login/totp.ts b/e2e/integration/real-github/login/totp.ts new file mode 100644 index 000000000..9ef21d596 --- /dev/null +++ b/e2e/integration/real-github/login/totp.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createHmac} from 'node:crypto'; + +// RFC 6238 TOTP. +export function generateTotp(secret: string, stepSeconds = 30): string { + const key = base32Decode(secret); + const counter = Math.floor(Date.now() / 1000 / stepSeconds); + const buf = Buffer.alloc(8); + buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0); + buf.writeUInt32BE(counter >>> 0, 4); + const hmac = createHmac('sha1', key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (code % 1_000_000).toString().padStart(6, '0'); +} + +function base32Decode(secret: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = ''; + for (const char of secret.toUpperCase().replace(/[=\s-]/g, '')) { + const value = alphabet.indexOf(char); + if (value >= 0) { + bits += value.toString(2).padStart(5, '0'); + } + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(parseInt(bits.slice(i, i + 8), 2)); + } + return Buffer.from(bytes); +} diff --git a/e2e/integration/real-github/rate_limit.ts b/e2e/integration/real-github/rate_limit.ts new file mode 100644 index 000000000..39ebac2b9 --- /dev/null +++ b/e2e/integration/real-github/rate_limit.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'node:fs'; +import path from 'node:path'; + +import {realGitHub} from './env'; + +// GitHub's secondary limits for content-creating requests. Kept on disk so back-to-back local runs share one budget. +const limits = [ + {windowMs: 60_000, max: 80}, + {windowMs: 60 * 60_000, max: 500}, +]; + +function readLog(): number[] { + try { + return JSON.parse(fs.readFileSync(realGitHub.writeLogPath, 'utf8')); + } catch { + return []; + } +} + +// Waits until one more write fits in every window, then records it. Call before each write, including ones the plugin makes. +export async function reserveWrite() { + for (;;) { + const now = Date.now(); + const log = readLog().filter((t) => now - t < limits.at(-1)!.windowMs); + const waitMs = Math.max(0, ...limits.map(({windowMs, max}) => { + const inWindow = log.filter((t) => now - t < windowMs); + return inWindow.length < max ? 0 : inWindow[inWindow.length - max] + windowMs - now; + })); + if (waitMs === 0) { + fs.mkdirSync(path.dirname(realGitHub.writeLogPath), {recursive: true}); + fs.writeFileSync(realGitHub.writeLogPath, JSON.stringify([...log, now])); + return; + } + if (waitMs > 5 * 60_000) { + throw new Error(`GitHub write budget exhausted for ${Math.ceil(waitMs / 60_000)} more minutes`); + } + await new Promise((r) => setTimeout(r, waitMs)); + } +} diff --git a/e2e/integration/scripts/visual.sh b/e2e/integration/scripts/visual.sh new file mode 100755 index 000000000..2d61420e8 --- /dev/null +++ b/e2e/integration/scripts/visual.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +PW_VERSION="$(node -p "require('@playwright/test/package.json').version")" +export PW_VERSION +# Baselines are tied to this exact server build; bump it together with test:visual-update-snapshots. +export MM_IMAGE=mattermost/mattermost-enterprise-edition:12.0.1 + +# A separate, throwaway stack: the pinned server must not start on a database another image already migrated. +export COMPOSE_PROJECT_NAME=github-plugin-e2e-visual +export MM_PORT="${VISUAL_MM_PORT:-8077}" +export PW_BROWSER_PORT="${VISUAL_BROWSER_PORT:-3337}" +trap 'docker compose --profile visual down -v >/dev/null 2>&1' EXIT + +docker compose --profile visual up -d --wait + +PW_VISUAL_BROWSER_WS="ws://127.0.0.1:${PW_BROWSER_PORT}/" npx playwright test --grep @visual "$@" diff --git a/e2e/integration/support/admin.ts b/e2e/integration/support/admin.ts new file mode 100644 index 000000000..e750b5b01 --- /dev/null +++ b/e2e/integration/support/admin.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@mattermost/client'; +import {testConfig} from '@mattermost/playwright-lib'; + +export async function loginAdmin(): Promise { + const client = new Client4(); + client.setUrl(testConfig.baseURL); + await client.login(testConfig.adminUsername, testConfig.adminPassword); + return client; +} diff --git a/e2e/integration/support/constants.ts b/e2e/integration/support/constants.ts new file mode 100644 index 000000000..178850ac4 --- /dev/null +++ b/e2e/integration/support/constants.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// docker compose reads the same file, so MM_PORT/MM_IMAGE stay in sync between the stack and the tests. +const envFile = path.join(__dirname, '../.env'); +if (fs.existsSync(envFile)) { + process.loadEnvFile(envFile); +} + +export const pluginId = 'github'; +export const pluginDistDir = path.join(__dirname, '../../../dist'); + +// Must match the server's SiteURL in docker-compose.yml. +process.env.PW_BASE_URL ??= `http://127.0.0.1:${process.env.MM_PORT ?? 8069}`; +export const mattermostURL = process.env.PW_BASE_URL; + +// Baked into the plugin by `make e2e-dist`. fake-github resolves from the Mattermost container (extra_hosts) +// and from Chromium (--host-resolver-rules); the tests reach it on localhost. +export const fakeGitHubHost = 'fake-github'; +export const fakeGitHubPort = 8093; +export const fakeGitHubURL = `http://${fakeGitHubHost}:${fakeGitHubPort}`; +export const fakeGitHubControlURL = `http://localhost:${fakeGitHubPort}`; + +export const oauthClientId = 'Iv1.e2e0123456789abc'; +export const oauthClientSecret = 'e2e-github-oauth-client-secret-0000000000'; +export const webhookSecret = 'e2e-github-webhook-secret'; + +// AES-256 needs a 32-byte key. +export const encryptionKey = 'e2e-encryption-key-0123456789abc'; + +export const webhookURL = `${mattermostURL}/plugins/${pluginId}/webhook`; + +export const oauthApp = { + clientId: oauthClientId, + clientSecret: oauthClientSecret, + callbackURL: `${mattermostURL}/plugins/${pluginId}/oauth/complete`, + name: 'Mattermost E2E', +}; diff --git a/e2e/integration/support/fake_github.ts b/e2e/integration/support/fake_github.ts new file mode 100644 index 000000000..52a1d6735 --- /dev/null +++ b/e2e/integration/support/fake_github.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {JournalEntry} from '../fake-github/server'; +import type {Account, Hook, Item, Thread, Token} from '../fake-github/state'; +import type {Delivery, WebhookRequest} from '../fake-github/webhooks'; + +import {fakeGitHubControlURL} from './constants'; + +async function control(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${fakeGitHubControlURL}/__control${path}`, { + method, + headers: {'Content-Type': 'application/json'}, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`fake-github control ${method} ${path} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; +} + +type ItemFields = Partial> & Pick; + +export const fakeGitHub = { + reset: () => control('POST', '/reset'), + requests: () => control('GET', '/requests'), + calls: async () => (await fakeGitHub.requests()).map((r) => `${r.method} ${r.path}`), + contractViolations: () => control('GET', '/contract-violations'), + settle: () => control('POST', '/settle'), + createUser: (login: string, extra: {name?: string; email?: string} = {}) => control('POST', '/users', {login, ...extra}), + createOrg: (login: string, options: {members?: string[]; admins?: string[]; ssoEnforced?: boolean} = {}) => control('POST', '/orgs', {login, ...options}), + setSSO: (org: string, enforced: boolean) => control('POST', '/sso', {org, enforced}), + createTeam: (org: string, slug: string, members: string[]) => control('POST', '/teams', {org, slug, members}), + createRepo: (fullName: string, options: {private?: boolean; archived?: boolean; admins?: string[]; writers?: string[]; labels?: string[]; milestones?: string[]} = {}) => control<{fullName: string; id: number}>('POST', '/repos', {fullName, ...options}), + createItem: (repo: string, fields: ItemFields) => control('POST', '/items', {repo, ...fields}), + updateItem: (repo: string, number: number, patch: Partial) => control('PATCH', '/items', {repo, number, ...patch}), + items: () => control('GET', '/items'), + createThread: (login: string, repo: string, fields: Partial = {}) => control('POST', '/threads', {login, repo, ...fields}), + createHook: (owner: string, repo: string | undefined, url: string, secret: string, events = ['*']) => control('POST', '/hooks', {owner, repo, url, secret, events}), + hooks: () => control('GET', '/hooks'), + issueToken: (login: string, scopes?: string[]) => control('POST', '/tokens', {login, scopes}), + grant: (login: string, scopes: string[]) => control<{scopes: string[]}>('POST', '/grants', {login, scopes}), + revoke: (token: string) => control('POST', '/revoke', {token}), + signIn: (login: string | null) => control('POST', '/sign-in', {login}), + setOAuthApp: (patch: {codeTTLms?: number; clientSecret?: string}) => control('POST', '/oauth-app', patch), + fault: (method: string, path: string, status: number, options: {body?: unknown; headers?: Record; times?: number} = {}) => control('POST', '/fault', {method, path, status, ...options}), + webhook: (request: WebhookRequest & {deliver?: boolean}) => control<{payload: Record; deliveries: Delivery[]}>('POST', '/webhooks', request), +}; diff --git a/e2e/integration/support/fixture.ts b/e2e/integration/support/fixture.ts new file mode 100644 index 000000000..578e05dca --- /dev/null +++ b/e2e/integration/support/fixture.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Channel} from '@mattermost/types/channels'; +import type {Team} from '@mattermost/types/teams'; +import type {UserProfile} from '@mattermost/types/users'; +import {expect, test as base} from '@mattermost/playwright-lib'; + +import type {Token} from '../fake-github/state'; + +import {fakeGitHub} from './fake_github'; +import {seedUserInfo} from './kv'; + +export {expect}; + +type GitHubConnection = {login: string; token: Token}; + +export type GitHubFixture = { + adminClient: Client4; + user: UserProfile; + userClient: Client4; + team: Team; + channel: Channel; + login: string; + connect: (mattermostUser?: UserProfile, options?: {login?: string; scopes?: string[]; allowedPrivateRepos?: boolean}) => Promise; +}; + +export type FailureScreenshotOptions = {failureScreenshots: boolean}; + +// pw.initSetup() is deliberately avoided: it resets server config, which drops the plugin's settings and PluginStates. +export const test = base.extend<{github: GitHubFixture; failureScreenshot: void}, FailureScreenshotOptions>({ + failureScreenshots: [false, {option: true, scope: 'worker'}], + + // Playwright's own failure screenshot runs after pw closes the context and only sees a blank page. + failureScreenshot: [async ({pw, failureScreenshots}, use, testInfo) => { + await use(); + if (!failureScreenshots || testInfo.status === testInfo.expectedStatus) { + return; + } + for (const [i, page] of (pw.testBrowser.context?.pages() ?? []).entries()) { + const body = await page.screenshot({fullPage: true}).catch(() => undefined); + if (body) { + await testInfo.attach(`failure-page-${i}`, {body, contentType: 'image/png'}); + } + } + }, {auto: true}], + + github: async ({pw}, use) => { + await fakeGitHub.reset(); + + const {adminClient} = await pw.getAdminClient(); + const team = await pw.createNewTeam(adminClient, {name: 'github', displayName: 'GitHub', type: 'O', unique: true}); + const user = await pw.createNewUserProfile(adminClient, {prefix: 'github', disableTutorial: true, disableOnboarding: true}); + await adminClient.addToTeam(team.id, user.id); + const {client: userClient} = await pw.makeClient(user); + if (!userClient) { + throw new Error(`Failed to log in ${user.username}`); + } + const channel = await adminClient.getChannelByName(team.id, 'town-square'); + + // GitHub logins are global; deriving them from the random Mattermost username keeps tests independent. + const login = `gh-${user.username}`; + await fakeGitHub.createUser(login); + + const connect: GitHubFixture['connect'] = async (mattermostUser = user, options = {}) => { + const ghLogin = options.login ?? (mattermostUser.id === user.id ? login : `gh-${mattermostUser.username}`); + const token = await fakeGitHub.issueToken(ghLogin, options.scopes); + seedUserInfo(mattermostUser.id, ghLogin, token.value, {allowedPrivateRepos: options.allowedPrivateRepos}); + return {login: ghLogin, token}; + }; + + await use({adminClient, user, userClient, team, channel, login, connect}); + + await fakeGitHub.settle(); + expect(await fakeGitHub.contractViolations(), 'fake-github traffic must match GitHub\'s REST, GraphQL and webhook schemas').toEqual([]); + }, +}); diff --git a/e2e/integration/support/kv.ts b/e2e/integration/support/kv.ts new file mode 100644 index 000000000..f2a699cb3 --- /dev/null +++ b/e2e/integration/support/kv.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import {execFileSync} from 'node:child_process'; +import {createCipheriv, createDecipheriv, randomBytes} from 'node:crypto'; +import path from 'node:path'; + +import {encryptionKey, pluginId} from './constants'; + +export type StoredUserInfo = { + UserID: string; + Token: {access_token: string; token_type: string; expiry: string}; + GitHubUsername: string; + LastToDoPostAt: number; + Settings: {sidebar_buttons: string; daily_reminder: boolean; daily_reminder_on_change: boolean; notifications: boolean}; + AllowedPrivateRepos: boolean; + MM34646ResetTokenDone: boolean; +}; + +const composeDir = path.join(__dirname, '..'); + +// Keys and format must match storeGitHubUserInfo and storeGitHubToUserIDMapping (server/plugin/plugin.go). +export function seedUserInfo(mattermostUserId: string, githubLogin: string, accessToken: string, options: {allowedPrivateRepos?: boolean; settings?: Partial; lastToDoPostAt?: number; key?: string} = {}) { + const info: StoredUserInfo = { + UserID: mattermostUserId, + + // Classic OAuth app tokens never expire; oauth2 stores the zero time. + Token: {access_token: encrypt(accessToken, options.key), token_type: 'bearer', expiry: '0001-01-01T00:00:00Z'}, + GitHubUsername: githubLogin, + + // A recent value keeps the daily reminder from posting into the DM channel tests assert on. + LastToDoPostAt: options.lastToDoPostAt ?? Date.now(), + Settings: {sidebar_buttons: 'team', daily_reminder: true, daily_reminder_on_change: false, notifications: true, ...options.settings}, + AllowedPrivateRepos: options.allowedPrivateRepos ?? false, + MM34646ResetTokenDone: true, + }; + upsert({ + [`${mattermostUserId}_githubtoken`]: JSON.stringify(info), + [`${githubLogin}_githubusername`]: mattermostUserId, + }); +} + +export function readUserInfo(mattermostUserId: string): StoredUserInfo | undefined { + const raw = get(`${mattermostUserId}_githubtoken`); + if (!raw) { + return undefined; + } + const info = JSON.parse(raw) as StoredUserInfo; + return {...info, Token: {...info.Token, access_token: decrypt(info.Token.access_token)}}; +} + +export function seedTokenResetDone() { + upsert({mm34646_token_reset_done: 'done'}); +} + +export function hasKey(key: string) { + return Boolean(get(key)); +} + +export function deleteKey(key: string) { + psql(`DELETE FROM pluginkeyvaluestore WHERE pluginid = '${pluginId}' AND pkey = ${quote(key)};`); +} + +function get(key: string) { + return psql(`SELECT convert_from(pvalue, 'UTF8') FROM pluginkeyvaluestore WHERE pluginid = '${pluginId}' AND pkey = ${quote(key)};`, ['-A', '-t']).trim(); +} + +function upsert(entries: Record) { + const values = Object.entries(entries).map(([key, value]) => `('${pluginId}', ${quote(key)}, convert_to(${quote(value)}, 'UTF8'), 0)`); + psql(`INSERT INTO pluginkeyvaluestore (pluginid, pkey, pvalue, expireat) VALUES ${values.join(', ')} + ON CONFLICT (pluginid, pkey) DO UPDATE SET pvalue = EXCLUDED.pvalue, expireat = 0;`); +} + +function psql(sql: string, flags: string[] = []) { + return execFileSync('docker', ['compose', 'exec', '-T', 'postgres', 'psql', '-U', 'mmuser', '-d', 'mattermost_test', '-v', 'ON_ERROR_STOP=1', '-q', ...flags], { + cwd: composeDir, + input: sql, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'inherit'], + env: process.env, + }); +} + +function quote(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +// Mirrors encrypt/decrypt in server/plugin/utils.go: PKCS#7 padding, AES-CFB with a random IV prefix, padded URL-safe base64. +export function encrypt(text: string, key = encryptionKey) { + const iv = randomBytes(16); + const plain = Buffer.from(text); + const padding = 16 - (plain.length % 16); + const cipher = createCipheriv('aes-256-cfb', Buffer.from(key), iv); + const body = Buffer.concat([cipher.update(Buffer.concat([plain, Buffer.alloc(padding, padding)])), cipher.final()]); + return Buffer.concat([iv, body]).toString('base64').replaceAll('+', '-').replaceAll('/', '_'); +} + +export function decrypt(text: string, key = encryptionKey) { + const data = Buffer.from(text.replaceAll('-', '+').replaceAll('_', '/'), 'base64'); + const decipher = createDecipheriv('aes-256-cfb', Buffer.from(key), data.subarray(0, 16)); + const plain = Buffer.concat([decipher.update(data.subarray(16)), decipher.final()]); + return plain.subarray(0, plain.length - plain[plain.length - 1]).toString(); +} diff --git a/e2e/integration/support/layout.ts b/e2e/integration/support/layout.ts new file mode 100644 index 000000000..d7f8caa6e --- /dev/null +++ b/e2e/integration/support/layout.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, type Locator} from '@playwright/test'; + +// Sub-pixel rounding differs between fonts and zoom levels. +const tolerance = 1; + +async function box(locator: Locator) { + const b = await locator.boundingBox(); + if (!b) { + throw new Error(`${locator} has no bounding box; is it visible?`); + } + return b; +} + +export async function expectInside(child: Locator, parent: Locator) { + const [c, p] = await Promise.all([box(child), box(parent)]); + expect({ + left: c.x >= p.x - tolerance, + top: c.y >= p.y - tolerance, + right: c.x + c.width <= p.x + p.width + tolerance, + bottom: c.y + c.height <= p.y + p.height + tolerance, + }, `${child} should sit inside ${parent}`).toEqual({left: true, top: true, right: true, bottom: true}); +} + +export async function expectNoOverlap(a: Locator, b: Locator) { + const [x, y] = await Promise.all([box(a), box(b)]); + const overlaps = x.x + tolerance < y.x + y.width && y.x + tolerance < x.x + x.width && + x.y + tolerance < y.y + y.height && y.y + tolerance < x.y + x.height; + expect(overlaps, `${a} and ${b} should not overlap`).toBe(false); +} + +export async function expectNothingClipped(root: Locator) { + const clipped = await root.evaluate((el, tol) => { + const offenders: string[] = []; + for (const node of [el, ...Array.from(el.querySelectorAll('*'))]) { + const style = getComputedStyle(node); + const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible' || style.textOverflow === 'ellipsis'; + if (clips && (node.scrollWidth > node.clientWidth + tol || node.scrollHeight > node.clientHeight + tol)) { + offenders.push(`<${node.tagName.toLowerCase()} class="${node.className}"> ${node.scrollWidth}x${node.scrollHeight} in ${node.clientWidth}x${node.clientHeight}`); + } + } + return offenders; + }, tolerance); + expect(clipped, `content under ${root} should not be clipped`).toEqual([]); +} diff --git a/e2e/integration/support/plugin.ts b/e2e/integration/support/plugin.ts new file mode 100644 index 000000000..6c8e2f73d --- /dev/null +++ b/e2e/integration/support/plugin.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type {Client4} from '@mattermost/client'; + +import {encryptionKey, oauthClientId, oauthClientSecret, pluginDistDir, pluginId, webhookSecret} from './constants'; +import {seedTokenResetDone} from './kv'; + +export const defaultPluginSettings = { + usepreregisteredapplication: false, + githuboauthclientid: oauthClientId, + githuboauthclientsecret: oauthClientSecret, + webhooksecret: webhookSecret, + encryptionkey: encryptionKey, + githuborg: '', + enterprisebaseurl: '', + enterpriseuploadurl: '', + enableleftsidebar: true, + enableprivaterepo: false, + connecttoprivatebydefault: false, + enablecodepreview: 'public', + enablewebhookeventlogging: false, + getnotificationfordraftprs: false, +}; + +export async function findPluginBundle(): Promise { + const files = await fs.readdir(pluginDistDir).catch(() => [] as string[]); + const bundles = await Promise.all( + files. + filter((name) => name.startsWith(`${pluginId}-`) && name.endsWith('.tar.gz')). + map(async (name) => { + const fullPath = path.join(pluginDistDir, name); + return {fullPath, mtime: (await fs.stat(fullPath)).mtimeMs}; + }), + ); + if (!bundles.length) { + throw new Error(`No ${pluginId} bundle found in ${pluginDistDir}; run \`make e2e-dist\` first`); + } + return bundles.sort((a, b) => b.mtime - a.mtime)[0].fullPath; +} + +export async function installPlugin(adminClient: Client4, settings: Partial = {}) { + const bundlePath = await findPluginBundle(); + const data = await fs.readFile(bundlePath); + const file = new File([data], path.basename(bundlePath), {type: 'application/gzip'}); + + await adminClient.uploadPlugin(file as unknown as globalThis.File, true); + await configureServer(adminClient, settings); + + // Otherwise the plugin resets every stored token through GitHub a minute after it starts. + seedTokenResetDone(); + await adminClient.enablePlugin(pluginId); + await waitForPluginRunning(adminClient); +} + +export async function patchPluginSettings(adminClient: Client4, settings: Partial) { + const config = await adminClient.getConfig(); + await adminClient.patchConfig({ + PluginSettings: { + Plugins: { + ...config.PluginSettings.Plugins, + [pluginId]: {...config.PluginSettings.Plugins[pluginId], ...settings}, + }, + }, + }); +} + +async function configureServer(adminClient: Client4, settings: Partial) { + await adminClient.patchConfig({ + ServiceSettings: {EnableTutorial: false, EnableOnboardingFlow: false}, + + // playwright-lib's random users use a short password that only passes these relaxed rules. + PasswordSettings: {MinimumLength: 5, Lowercase: false, Number: false, Uppercase: false, Symbol: false}, + }); + await patchPluginSettings(adminClient, {...defaultPluginSettings, ...settings}); +} + +export async function waitForPluginRunning(adminClient: Client4, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let lastState = 'unknown'; + while (Date.now() < deadline) { + const statuses = await adminClient.getPluginStatuses(); + const status = statuses.find((s) => s.plugin_id === pluginId); + if (status?.state === 2) { + return; + } + lastState = status ? `state=${status.state}` : 'not installed'; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Plugin ${pluginId} did not reach Running: ${lastState}`); +} diff --git a/e2e/integration/support/seed.ts b/e2e/integration/support/seed.ts new file mode 100644 index 000000000..a145ed5e2 --- /dev/null +++ b/e2e/integration/support/seed.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {GitHubFixture} from './fixture'; +import {fakeGitHub} from './fake_github'; + +export const repo = 'acme/widgets'; +export const labels = ['bug', 'enhancement', 'needs-design-review-from-the-platform-team']; +export const longTitle = 'Refactor the widget polishing pipeline so that it handles extremely long pull request titles gracefully '.repeat(2).trim(); + +// A fixed date keeps the tooltip's date line identical between runs. +const createdAt = '2024-01-15T12:00:00Z'; + +export async function seedWork(github: GitHubFixture) { + const dates = {createdAt, updatedAt: createdAt}; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [github.login, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: [github.login, 'octo-author'], labels, milestones: ['v1.0']}); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: longTitle, author: github.login, labels, body: 'Splits polishing into stages.', ...dates}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [github.login], ...dates}); + await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', assignees: [github.login], milestone: 1, ...dates}); + await fakeGitHub.createThread(github.login, repo, {number: pr.number, reason: 'mention'}); + await github.connect(); + return pr; +} diff --git a/e2e/integration/support/ui.ts b/e2e/integration/support/ui.ts new file mode 100644 index 000000000..12dfc96be --- /dev/null +++ b/e2e/integration/support/ui.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Post} from '@mattermost/types/posts'; +import type {UserProfile} from '@mattermost/types/users'; +import {expect, type ChannelsPage, type PlaywrightExtended} from '@mattermost/playwright-lib'; +import type {Locator, Page} from '@playwright/test'; + +export async function openChannel(pw: PlaywrightExtended, user: UserProfile, teamName: string, channelName = 'town-square') { + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(teamName, channelName); + await channelsPage.toBeVisible(); + return {channelsPage, page}; +} + +export async function runSlashCommand(channelsPage: ChannelsPage, command: string) { + const postCreate = channelsPage.centerView.postCreate; + await postCreate.input.fill(command); + + // Enter would pick the highlighted slash-command autocomplete suggestion instead of sending. + await postCreate.sendMessageButton.click(); + await expect(postCreate.input).toHaveValue(''); +} + +export function sidebarButton(page: Page, icon: 'fa-compress' | 'fa-code-fork' | 'fa-list-ol' | 'fa-envelope') { + return page.locator(`a:has(> i.${icon})`).first(); +} + +export function sidebarButtonBar(page: Page) { + return sidebarButton(page, 'fa-compress').locator('xpath=ancestor::div[.//i[contains(@class, "fa-envelope")]][1]'); +} + +// Swaps per-run values (random usernames) for fixed text so screenshots are comparable. +export async function replaceText(target: Locator, replacements: Array<[string | RegExp, string]>) { + await target.evaluate((root, pairs) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + for (const [source, flags, to] of pairs) { + node.textContent = node.textContent!.replace(new RegExp(source, flags), to); + } + } + }, replacements.map(([from, to]) => (typeof from === 'string' ? [from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g', to] : [from.source, from.flags, to]))); +} + +export async function openCreateIssueModal(channelsPage: ChannelsPage, page: Page) { + const post = await channelsPage.getLastPost(); + await post.hover(); + await post.postMenu.actionsButton.click(); + await page.getByRole('menuitem', {name: 'Create GitHub Issue'}).click(); + const modal = page.getByRole('dialog').filter({hasText: 'Create GitHub Issue'}); + await expect(modal.getByLabel('Title for the GitHub Issue')).toBeVisible(); + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().endTime !== Infinity).map((a) => a.finished))); + return modal; +} + +// The react-select menus in the plugin don't expose role=option, so pick by filtering to one match. +export async function chooseOption(page: Page, field: Locator, option: string) { + await field.getByRole('combobox').click(); + await page.keyboard.type(option); + await expect(field.getByRole('log')).toContainText(/1 result available|option .* focused/); + await page.keyboard.press('Enter'); + await expect(field).toContainText(option); +} + +export function selectField(modal: Locator, label: string) { + return modal.getByText(label, {exact: true}).locator('xpath=..'); +} + +export function postWithText(page: Page, text: string | RegExp) { + return page.locator('.post').filter({hasText: text}).last(); +} + +export async function channelPosts(client: Client4, channelId: string): Promise { + const {order, posts} = await client.getPosts(channelId); + return order.map((id) => posts[id]); +} + +export async function botDMPosts(client: Client4, userId: string): Promise { + const bot = await client.getUserByUsername('github'); + const channel = await client.createDirectChannel([userId, bot.id]); + return channelPosts(client, channel.id); +} + +export async function waitForPost(load: () => Promise, match: (post: Post) => boolean, message: string): Promise { + let found: Post | undefined; + await expect.poll(async () => { + found = (await load()).find(match); + return Boolean(found); + }, {message, timeout: 15_000}).toBe(true); + return found!; +} diff --git a/e2e/integration/support/visual.css b/e2e/integration/support/visual.css new file mode 100644 index 000000000..cf371e8f3 --- /dev/null +++ b/e2e/integration/support/visual.css @@ -0,0 +1,4 @@ +/* Core UI that overlaps plugin posts and changes between runs. */ +.post .status { + visibility: hidden !important; +} diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..de6ecf46c17f599df09e7d712e1c00ad895c6b86 GIT binary patch literal 20631 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 BFqZ%T literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/profile-popover.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/profile-popover.png new file mode 100644 index 0000000000000000000000000000000000000000..c78e069edf14b09ca4f14efc30c7a0facc291481 GIT binary patch literal 1437 zcmV;O1!DS%P)3kcu*z1}Njjg5|eO5S)3S~c@$SX`2 zP#k>nzSivL&*mDv)M0_+#ChY#8-qhcepbHY=Ztz2 zvpP$NJvgSrCt_k)RvOz@tumOHf;7Hs6vwfA8DC>$w$~_W2J<6~9c#R7!X7Bo;S+IJ z@b8-R6-PC)IBvy;Adk6ME0rzn=4~s*g`=VnxYljkU#C&)>5g6-v`PYntlcGni(|Nu zK=T$*XWC5iE=!n%T$w5KFmz$I+7BGpLcZ1*n7BxGX`v`)HrqAc)N0ZVGl@~0_t#6d z*QhSEo1IoqFX5N17Ej(5vOoOP>XTYfXz1ubW`8ALL=xDRN~MQS3^DzXCodS`Ek2Ut zJ(oC#1EKa~i*aFiRyvb}UlJNfz{tW!xgfU~!cUNF635JTNIKqgMvG&RV2xRXN&*Nz7jL>^ z%1q?-sOYP=tZ%MxD`ISf`!k=*GMdM6+pVHOV-lq9D`O%!T?01n@%y)Jzh(XEl&-Va zCZt#cLv);!jUoI53HN<#rv)ToNi5f64$>36Mu&~bfKcnay-NI6jS38T9DL{6Kxe7s zgg0Ju$-xs!nJX{Wc8Y zCrD_XsdO}~K;QU6wQLcUlj7)#HC!BS_`~@7zmD{`JWb$Xb53sF^vsO4Ig>4PR%Hp` zaGgF9L=r5lO<_rj8WExPE=$IIDqKQ9K!6SGk^ON?AN_MTR>e=I!XI+#E7DoWu}_J2Mg=5K={txud5jU2rD@=*^yOGd-2c2N{FY z?|(DE;%-hTiZjGrz^D3Xq}Irk9aKZz6EEdJkq(Q>lH35l?~@`PesJ^@F2Xk;HA47^ r{{a91|Nr|6C+7eF00v1!K~w_(@T|r>QmNH~00000NkvXXu0mjfXo#t) literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/reply-thread.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/reply-thread.png new file mode 100644 index 0000000000000000000000000000000000000000..18e5fe12375be2c36a408a9adab02882c6e62885 GIT binary patch literal 24959 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 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-review-requests.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-review-requests.png new file mode 100644 index 0000000000000000000000000000000000000000..ecaa04890dd1c0420a409568cfa71fecb0e85529 GIT binary patch literal 19211 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 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-unreads.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-unreads.png new file mode 100644 index 0000000000000000000000000000000000000000..1428d95ad175e7111a2faca89ecf37c30c027bbf GIT binary patch literal 18813 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 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-issue.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-issue.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ff775c0a16fb0ec59f6e7bfc1f597d037127c3 GIT binary patch literal 7756 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 MeUTTh02uoH56DFX1poj5 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-merged-pull.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-merged-pull.png new file mode 100644 index 0000000000000000000000000000000000000000..0f1a6c8cd6794998ccbcc2b0e93f45d40fcc9f2e GIT binary patch literal 11640 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
i3UYeqU7{%}2OiO=;^&N!c%|9;pWcK}I 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 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-pull.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-pull.png new file mode 100644 index 0000000000000000000000000000000000000000..e7bb9eb25f9855ab2fd3883bef5a417122eb5c76 GIT binary patch literal 24609 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 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-push.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-push.png new file mode 100644 index 0000000000000000000000000000000000000000..af0192386a2286018fe3b0f8527f1c7a43de9a17 GIT binary patch literal 7971 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 WT-~`Jee0iF5ENuoq{}2f2K_%F1crD3 literal 0 HcmV?d00001 diff --git a/e2e/integration/tests/connect.spec.ts b/e2e/integration/tests/connect.spec.ts new file mode 100644 index 000000000..1322783a3 --- /dev/null +++ b/e2e/integration/tests/connect.spec.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; + +import {defaultScopes} from '../fake-github/server'; +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {hasKey, readUserInfo} from '../support/kv'; +import {botDMPosts, openChannel, postWithText, runSlashCommand, waitForPost} from '../support/ui'; + +const completePath = /\/plugins\/github\/oauth\/complete/; + +// `/github connect` answers with a link that opens the OAuth flow in a new tab, which closes itself on success. +async function startConnect(page: Page, channelsPage: Parameters[0]) { + await runSlashCommand(channelsPage, '/github connect'); + const complete = page.context().waitForEvent('response', {predicate: (r) => completePath.test(r.url()), timeout: 30_000}); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + postWithText(page, 'Click here to link your GitHub account').getByRole('link', {name: 'Click here to link your GitHub account'}).click(), + ]); + return {popup, complete}; +} + +test.describe('OAuth connect', () => { + test('connects through GitHub\'s consent page and stores the classic token', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await expect(popup.getByRole('heading', {name: 'Authorize Mattermost E2E'})).toBeVisible(); + await expect(popup.getByRole('listitem')).toHaveText(defaultScopes.slice().sort()); + + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + expect((await complete).status()).toBe(200); + + const stored = readUserInfo(github.user.id); + expect(stored).toMatchObject({GitHubUsername: github.login, AllowedPrivateRepos: false, MM34646ResetTokenDone: true, Token: {token_type: 'bearer'}}); + expect(stored!.Token.access_token).toMatch(/^gho_/); + + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_welcome', 'welcome DM'); + expect(await fakeGitHub.calls()).toEqual(expect.arrayContaining(['GET /login/oauth/authorize', 'POST /login/oauth/access_token', 'GET /api/user'])); + }); + + test('skips the consent page when the app was already authorized for these scopes', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.grant(github.login, defaultScopes); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {complete} = await startConnect(page, channelsPage); + + expect((await complete).status()).toBe(200); + expect(readUserInfo(github.user.id)?.GitHubUsername).toBe(github.login); + expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/authorize'); + }); + + // GitHub grants the union of every scope the user ever approved for the app, so an old `repo` grant comes back. + test('rejects a token that carries private repo scope from an earlier authorization', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.grant(github.login, ['repo']); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + const response = await complete; + + expect(response.status()).toBe(403); + expect(await response.text()).toContain('OAuth token scope does not match the requested permissions'); + expect(readUserInfo(github.user.id)).toBeUndefined(); + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => JSON.stringify(p.props).includes('OAuth token scope does not match'), 'scope mismatch DM'); + }); + + test('rejects a callback whose state was never issued', async ({pw, github}) => { + const {page} = await openChannel(pw, github.user, github.team.name); + + const response = await page.goto('/plugins/github/oauth/complete?code=0123456789abcdef0123&state=forgedstate0000'); + + expect(response?.status()).toBe(400); + expect(await response?.text()).toContain('invalid state token'); + expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/access_token'); + }); + + // GitHub reports token-exchange errors with HTTP 200 and an error field. + test('fails cleanly when GitHub rejects an expired authorization code', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.setOAuthApp({codeTTLms: 0}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + const response = await complete; + + expect(response.status()).toBe(500); + expect(await response.text()).toContain('failed to exchange oauth code into token'); + expect(readUserInfo(github.user.id)).toBeUndefined(); + const exchange = (await fakeGitHub.requests()).filter((r) => r.path === '/login/oauth/access_token'); + expect(exchange.length).toBeGreaterThan(0); + expect(exchange.every((r) => r.status === 200)).toBe(true); + }); + + test('/github disconnect removes the stored token', async ({pw, github}) => { + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github disconnect'); + + await expect(postWithText(page, 'Disconnected your GitHub account.')).toBeVisible(); + expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); + expect(hasKey(`${github.login}_githubusername`)).toBe(false); + }); +}); + +test.describe('auth failures', () => { + test('a revoked token disconnects the user and DMs them to reconnect', async ({pw, github}) => { + const {token} = await github.connect(); + await fakeGitHub.revoke(token.value); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github me'); + + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_revoked_token', 'revoked-token DM'); + expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); + expect((await fakeGitHub.requests()).some((r) => r.path.startsWith('/api/') && r.status === 401)).toBe(true); + }); + + test('SAML enforcement keeps the connection and DMs a re-authorize link once', async ({pw, github}) => { + await fakeGitHub.createOrg('acme-sso', {members: [github.login], ssoEnforced: true}); + await fakeGitHub.createRepo('acme-sso/widgets'); + await github.connect(); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); + await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); + + const dm = await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => p.message.includes('SAML SSO session expired'), 'SAML DM'); + expect(dm.message).toContain('https://github.com/orgs/acme-sso/sso?authorization_request='); + await expect.poll(async () => (await botDMPosts(github.adminClient, github.user.id)).filter((p) => p.message.includes('SAML SSO session expired')).length).toBe(1); + expect(readUserInfo(github.user.id)?.GitHubUsername).toBe(github.login); + }); +}); diff --git a/e2e/integration/tests/contract.spec.ts b/e2e/integration/tests/contract.spec.ts new file mode 100644 index 000000000..631cd95ca --- /dev/null +++ b/e2e/integration/tests/contract.spec.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@playwright/test'; + +import {skeleton, validateGraphQLQuery, validateResponse, validateWebhook, webhookSkeleton} from '../fake-github/contract/validator'; +import {type FakeGitHub, startFakeGitHub} from '../fake-github/server'; +import {State, normalizeScopes, parseSearch} from '../fake-github/state'; +import {buildPayload, signatures} from '../fake-github/webhooks'; + +function seededState() { + const state = new State(); + state.addUser('alice'); + state.addOrg('acme', [], ['alice']); + state.addItem(state.addRepo('acme/widgets'), {kind: 'issue', title: 'Broken widget', author: 'alice'}); + return state; +} + +// These check the fake against GitHub's published schemas without a Mattermost server. +const port = 8193; +const base = `http://localhost:${port}`; +const app = {clientId: 'Iv1.contract', clientSecret: 'contract-secret', callbackURL: 'http://localhost:1/plugins/github/oauth/complete', name: 'Contract'}; + +test.describe('contract', () => { + let fake: FakeGitHub; + let token: string; + + const control = async (path: string, body: unknown) => (await fetch(`${base}/__control${path}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})).json(); + const api = (path: string, init: RequestInit = {}) => fetch(`${base}/api${path}`, {...init, headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init.headers}}); + + test.beforeAll(async () => { + fake = await startFakeGitHub({port, publicURL: base, app}); + }); + test.afterAll(() => fake.close()); + + test.beforeEach(async () => { + await control('/reset', {}); + await control('/users', {login: 'alice'}); + await control('/users', {login: 'bob'}); + await control('/orgs', {login: 'acme', members: ['alice', 'bob'], admins: ['alice']}); + await control('/teams', {org: 'acme', slug: 'core', members: ['bob']}); + await control('/repos', {fullName: 'acme/widgets', milestones: ['v1']}); + await control('/items', {repo: 'acme/widgets', kind: 'pull', title: 'Add widget', author: 'alice', reviewers: ['bob'], teamReviewers: ['core'], labels: ['bug']}); + await control('/items', {repo: 'acme/widgets', kind: 'issue', title: 'Broken widget', author: 'bob', assignees: ['alice'], milestone: 1}); + await control('/threads', {login: 'alice', repo: 'acme/widgets', number: 1, reason: 'review_requested'}); + token = (await control('/tokens', {login: 'alice'})).value; + }); + + test.afterEach(async () => { + await fetch(`${base}/__control/settle`, {method: 'POST'}); + expect(await (await fetch(`${base}/__control/contract-violations`)).json()).toEqual([]); + }); + + test('every REST route the plugin uses answers with a schema-valid body', async () => { + for (const path of ['/user', '/users/bob', '/user/orgs', '/user/repos', '/users/alice/repos', '/orgs/acme', '/orgs/acme/repos', '/orgs/acme/hooks', + '/repos/acme/widgets', '/repos/acme/widgets/issues/2', '/repos/acme/widgets/labels', '/repos/acme/widgets/assignees', '/repos/acme/widgets/milestones', + '/repos/acme/widgets/pulls/1', '/repos/acme/widgets/pulls/1/reviews', '/search/issues?q=is%3Apr+review-requested%3Abob', '/notifications']) { + expect((await api(path)).status, path).toBe(200); + } + expect((await api('/orgs/acme/members/bob')).status).toBe(204); + expect((await api('/repos/acme/widgets/issues', {method: 'POST', body: JSON.stringify({title: 'New', labels: ['bug']})})).status).toBe(201); + expect((await api('/repos/acme/widgets/issues/1/comments', {method: 'POST', body: JSON.stringify({body: 'hi'})})).status).toBe(201); + }); + + test('answers like GitHub for bad credentials, missing scopes and SAML', async () => { + const bad = await fetch(`${base}/api/user`, {headers: {Authorization: 'Bearer gho_nope'}}); + expect(bad.status).toBe(401); + expect(await bad.json()).toMatchObject({message: 'Bad credentials'}); + await control('/revoke', {token}); + expect((await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(type: ISSUE, query: "x", first: 1) { issueCount } }'})})).status).toBe(401); + token = (await control('/tokens', {login: 'alice'})).value; + + expect((await api('/repos/acme/widgets/hooks')).status).toBe(404); + await control('/users', {login: 'dave'}); + const narrow = (await control('/tokens', {login: 'dave', scopes: ['public_repo']})).value; + expect((await fetch(`${base}/api/user/orgs`, {headers: {Authorization: `Bearer ${narrow}`}})).status).toBe(403); + expect((await api('/user')).headers.get('x-oauth-scopes')).toBe('admin:org_hook, notifications, public_repo, read:org'); + + await control('/sso', {org: 'acme', enforced: true}); + const saml = await api('/repos/acme/widgets'); + expect(saml.status).toBe(403); + expect(saml.headers.get('x-github-sso')).toMatch(/^required; url=https:\/\/github\.com\/orgs\/acme\/sso\?authorization_request=/); + const search = await api('/search/issues?q=author%3Aalice'); + expect((await search.json()).total_count).toBe(0); + expect(search.headers.get('x-github-sso')).toMatch(/^partial-results; organizations=\d+$/); + + await control('/users', {login: 'carol'}); + const outsider = (await control('/tokens', {login: 'carol'})).value; + expect((await fetch(`${base}/api/repos/acme/widgets`, {headers: {Authorization: `Bearer ${outsider}`}})).status).toBe(200); + }); + + test('commenting through the API delivers issue_comment to the repository hook', async () => { + await control('/hooks', {owner: 'acme', repo: 'widgets', url: `${base}/`, secret: 's'}); + expect((await api('/repos/acme/widgets/issues/2/comments', {method: 'POST', body: JSON.stringify({body: 'on it'})})).status).toBe(201); + await fetch(`${base}/__control/settle`, {method: 'POST'}); + const delivered = (await (await fetch(`${base}/__control/requests`)).json()).filter((r: {method: string; path: string; body?: {comment?: {body: string}}}) => r.method === 'POST' && r.path === '/'); + expect(delivered.map((r: {body?: {comment?: {body: string}}}) => r.body?.comment?.body)).toEqual(['on it']); + }); + + test('paginates with Link headers', async () => { + for (let i = 0; i < 4; i++) { + await control('/items', {repo: 'acme/widgets', kind: 'issue', title: `Bulk ${i}`, author: 'alice'}); + } + const first = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2'); + expect(first.headers.get('link')).toContain('rel="next"'); + const last = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2&page=3'); + expect(last.headers.get('link')).not.toContain('rel="next"'); + expect((await last.json()).items).toHaveLength(2); + }); + + test('the plugin\'s sidebar and digest queries validate and execute against GitHub\'s schema', async () => { + const query = `query($q: String!, $c: String) { pullRequest: search(first: 100, after: $c, query: $q, type: ISSUE) { issueCount pageInfo { hasNextPage endCursor } + nodes { ... on PullRequest { body number authorAssociation createdAt updatedAt repository { name nameWithOwner url owner { login } } state title author { login } url + labels(first: 100) { nodes { name color } } milestone { title } additions deletions changedFiles + reviewRequests(first: 100) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug organization { login } } } } } } + ... on Issue { number title state url milestone { title } } } } }`; + expect(validateGraphQLQuery(query).errors).toEqual([]); + + const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query, variables: {q: 'review-requested:bob is:pr is:OPEN archived:false org:acme'}})}); + const {data} = await res.json(); + expect(data.pullRequest.issueCount).toBe(1); + expect(data.pullRequest.nodes[0].reviewRequests.nodes.map((n: any) => n.requestedReviewer)).toEqual([ + {__typename: 'User', login: 'bob'}, + {__typename: 'Team', slug: 'core', organization: {login: 'acme'}}, + ]); + }); + + test('GraphQL rejects unknown fields and missing pagination bounds like GitHub', async () => { + expect(validateGraphQLQuery('{ search(first: 1, query: "x", type: ISSUE) { nodes { ... on Issue { nope } } } }').errors).toHaveLength(1); + const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(query: "x", type: ISSUE) { issueCount } }'})}); + expect(res.status).toBe(200); + expect((await res.json()).errors[0].message).toContain('You must provide a `first` or `last` value'); + }); + + test('OAuth token exchange errors come back as HTTP 200, as form or JSON by Accept header', async () => { + const form = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', body: new URLSearchParams({client_id: app.clientId, client_secret: app.clientSecret, code: 'nope'})}); + expect(form.status).toBe(200); + expect(Object.fromEntries(new URLSearchParams(await form.text()))).toMatchObject({error: 'bad_verification_code'}); + + const json = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', headers: {Accept: 'application/json'}, body: new URLSearchParams({client_id: app.clientId, client_secret: 'wrong', code: 'nope'})}); + expect(await json.json()).toMatchObject({error: 'incorrect_client_credentials'}); + }); + + test('webhook payloads match GitHub\'s event schemas and carry both signatures', async () => { + const state = fake.state; + for (const request of [ + {event: 'pull_request', action: 'opened', number: 1}, + {event: 'pull_request', action: 'review_requested', number: 1, requestedReviewer: 'bob'}, + {event: 'issues', action: 'opened', number: 2}, + {event: 'issue_comment', action: 'created', number: 2}, + {event: 'push'}, + ]) { + const payload = buildPayload(state, {repo: 'acme/widgets', sender: 'alice', ...request}); + expect(validateWebhook(request.event, payload), `${request.event}.${request.action}`).toEqual([]); + } + expect(signatures('secret', '{}')).toEqual({ + 'X-Hub-Signature': expect.stringMatching(/^sha1=[0-9a-f]{40}$/), + 'X-Hub-Signature-256': expect.stringMatching(/^sha256=[0-9a-f]{64}$/), + }); + }); + + test('creating a hook sends GitHub\'s ping delivery', async () => { + const received: string[] = []; + const {createServer} = await import('node:http'); + const sink = createServer((req, res) => { + received.push(String(req.headers['x-github-event'])); + res.end(); + }); + await new Promise((resolve) => sink.listen(8194, resolve)); + try { + const res = await api('/orgs/acme/hooks', {method: 'POST', body: JSON.stringify({name: 'web', config: {url: 'http://localhost:8194/hook', content_type: 'json', secret: 's'}, events: ['*']})}); + expect(res.status).toBe(201); + await expect.poll(() => received).toEqual(['ping']); + } finally { + sink.close(); + } + }); +}); + +test.describe('fake semantics', () => { + test('search qualifiers are case-insensitive, repeated org: is OR, and @me resolves to the viewer', () => { + expect(parseSearch('is:pr is:OPEN org:a org:b author:@me "two words"', 'alice')).toEqual([ + {qualifier: 'is', values: ['pr'], negated: false}, + {qualifier: 'is', values: ['OPEN'], negated: false}, + {qualifier: 'org', values: ['a', 'b'], negated: false}, + {qualifier: 'author', values: ['alice'], negated: false}, + {qualifier: 'text', values: ['two words'], negated: false}, + ]); + }); + + test('scopes covered by a broader grant are folded away', () => { + expect(normalizeScopes(['public_repo', 'repo', 'read:org', 'notifications'])).toEqual(['notifications', 'read:org', 'repo']); + expect(normalizeScopes(['read:org', 'admin:org'])).toEqual(['admin:org']); + }); + + test('skeletons satisfy their own schemas', () => { + expect(validateResponse('GET', '/user', 200, skeleton('private-user'))).toEqual([]); + expect(validateWebhook('ping', webhookSkeleton('ping'))).toEqual([]); + }); + + test('the fake rejects payloads GitHub would never send', () => { + expect(validateResponse('GET', '/user', 200, {login: 'x'})).not.toEqual([]); + const payload = buildPayload(seededState(), {event: 'issues', action: 'opened', repo: 'acme/widgets', number: 1, sender: 'alice'}); + expect(validateWebhook('issues', {...payload, action: 'teleported'})).not.toEqual([]); + }); +}); diff --git a/e2e/integration/tests/issues.spec.ts b/e2e/integration/tests/issues.spec.ts new file mode 100644 index 000000000..674806d3e --- /dev/null +++ b/e2e/integration/tests/issues.spec.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {channelPosts, chooseOption, openChannel, openCreateIssueModal, postWithText, selectField, waitForPost} from '../support/ui'; + +const repo = 'acme/widgets'; + +async function seedRepo(github: GitHubFixture, options: {canPush?: boolean} = {}) { + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [github.login, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: options.canPush === false ? ['octo-author'] : [github.login, 'octo-author'], labels: ['bug', 'enhancement'], milestones: ['v1.0']}); +} + +test.describe('link tooltip', () => { + test('shows pull request details for a github.com link', async ({pw, github}) => { + await seedRepo(github); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: 'octo-author', labels: ['enhancement'], head: 'polish', base: 'main'}); + await github.connect(); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: `Have a look: ${url}`} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await postWithText(page, 'Have a look').getByRole('link', {name: url}).hover(); + + const tooltip = page.locator('.github-tooltip').first(); + await expect(tooltip).toContainText('Add widget polishing'); + await expect(tooltip).toContainText(`#${pr.number}`); + await expect(tooltip.locator('.opened-by')).toContainText('octo-author'); + await expect(tooltip.locator('.commit-ref')).toHaveText(['main', 'polish']); + await expect(tooltip.locator('.labels')).toContainText('enhancement'); + expect(await fakeGitHub.calls()).toContain(`GET /api/repos/${repo}/pulls/${pr.number}`); + }); + + test('shows issue details for a github.com link', async ({pw, github}) => { + await seedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', body: 'Every Monday, without fail.'}); + await github.connect(); + const url = `https://github.com/${repo}/issues/${issue.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await page.getByRole('link', {name: url}).last().hover(); + + const tooltip = page.locator('.github-tooltip').first(); + await expect(tooltip).toContainText('Widgets squeak on Mondays'); + await expect(tooltip).toContainText('Every Monday, without fail.'); + }); +}); + +test.describe('create issue from a post', () => { + async function openCreateIssue(pw: Parameters[0], github: GitHubFixture, message: string) { + await github.userClient.createPost({channel_id: github.channel.id, message} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + return {page, modal: await openCreateIssueModal(channelsPage, page)}; + } + + test('creates the issue with labels, assignee and milestone and links back to the post', async ({pw, github}) => { + await seedRepo(github); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'The widget squeaks when it rains'); + + await expect(selectField(modal, 'Organization')).toContainText(github.login); + await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); + await modal.getByLabel('Title for the GitHub Issue').fill('Widget squeaks in the rain'); + await chooseOption(page, selectField(modal, 'Labels'), 'bug'); + await chooseOption(page, selectField(modal, 'Assignees'), github.login); + await chooseOption(page, selectField(modal, 'Milestone'), 'v1.0'); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + + const created = (await fakeGitHub.items()).find((i) => i.title === 'Widget squeaks in the rain'); + expect(created).toMatchObject({kind: 'issue', author: github.login, labels: ['bug'], assignees: [github.login], milestone: 1}); + expect(created!.body).toContain('The widget squeaks when it rains'); + const reply = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); + expect(reply.message).toContain(`https://github.com/${repo}/issues/${created!.number}`); + }); + + // GitHub silently drops labels, assignees and milestones from users without push access; the modal hides them. + test('hides issue attributes for a repository the user cannot push to', async ({pw, github}) => { + await seedRepo(github, {canPush: false}); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'Read-only repo message'); + + await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); + + await expect(modal.getByText('Labels', {exact: true})).toHaveCount(0); + await expect(modal.getByText('Assignees', {exact: true})).toHaveCount(0); + await modal.getByLabel('Title for the GitHub Issue').fill('Filed without push access'); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + expect((await fakeGitHub.items()).find((i) => i.title === 'Filed without push access')).toMatchObject({labels: [], assignees: []}); + }); + + test('switching organization lists that organization\'s repositories', async ({pw, github}) => { + await seedRepo(github); + await fakeGitHub.createOrg('gizmo-co', {members: [github.login]}); + await fakeGitHub.createRepo('gizmo-co/gizmos', {writers: [github.login]}); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'Which org?'); + + await chooseOption(page, selectField(modal, 'Organization'), 'gizmo-co'); + + await expect.poll(() => fakeGitHub.calls(), {timeout: 5_000}).toContain('GET /api/orgs/gizmo-co/repos'); + await chooseOption(page, selectField(modal, 'Repository'), 'gizmos'); + }); +}); diff --git a/e2e/integration/tests/sidebar.spec.ts b/e2e/integration/tests/sidebar.spec.ts new file mode 100644 index 000000000..5d053aedc --- /dev/null +++ b/e2e/integration/tests/sidebar.spec.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, sidebarButton} from '../support/ui'; + +const repo = 'acme/widgets'; + +async function seedWork(github: GitHubFixture) { + const me = github.login; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [me, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: [me, 'octo-author'], milestones: ['v1.0']}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: me, labels: ['enhancement'], additions: 42, deletions: 7, changedFiles: 3}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Fix widget wobble', author: me, ciState: 'failure'}); + const review = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [me]}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Closed widget work', author: me, state: 'closed'}); + await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', assignees: [me], milestone: 1}); + await fakeGitHub.createThread(me, repo, {number: review.number, reason: 'review_requested'}); +} + +test('sidebar counts and RHS lists come from GitHub search and notifications', async ({pw, github}) => { + await seedWork(github); + await github.connect(); + const {page} = await openChannel(pw, github.user, github.team.name); + + await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); + await expect(sidebarButton(page, 'fa-code-fork')).toHaveText('1'); + await expect(sidebarButton(page, 'fa-list-ol')).toHaveText('1'); + await expect(sidebarButton(page, 'fa-envelope')).toHaveText('1'); + + const rhs = page.locator('#rhsContainer'); + await sidebarButton(page, 'fa-compress').click(); + await expect(rhs.getByRole('link', {name: 'Your Open Pull Requests'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Add widget polishing'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Fix widget wobble'})).toBeVisible(); + await expect(rhs.getByText('Closed widget work')).toHaveCount(0); + await expect(rhs.getByText('3 Files Changed')).toBeVisible(); + await expect(rhs.getByLabel('Success')).toHaveCount(1); + + await sidebarButton(page, 'fa-code-fork').click(); + await expect(rhs.getByRole('link', {name: 'Pull Requests Needing Review'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Please review the gizmo'})).toBeVisible(); + + await sidebarButton(page, 'fa-list-ol').click(); + await expect(rhs.getByRole('link', {name: 'Your Assignments'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Widgets squeak on Mondays'})).toBeVisible(); + await expect(rhs.getByRole('note', {name: 'v1.0'})).toBeVisible(); + + await sidebarButton(page, 'fa-envelope').click(); + await expect(rhs.getByRole('link', {name: 'Unread Messages'})).toBeVisible(); + await expect(rhs.getByText('Please review the gizmo')).toBeVisible(); + + const calls = await fakeGitHub.calls(); + expect(calls).toEqual(expect.arrayContaining(['POST /api/graphql', 'GET /api/notifications', `GET /api/repos/${repo}/pulls/1/reviews`, `GET /api/repos/${repo}/pulls/1`])); +}); + +test('an org lock limits the sidebar to that organization', async ({pw, github}) => { + await seedWork(github); + await fakeGitHub.createOrg('elsewhere', {members: [github.login]}); + await fakeGitHub.createRepo('elsewhere/gadgets', {writers: [github.login]}); + await fakeGitHub.createItem('elsewhere/gadgets', {kind: 'pull', title: 'Gadget PR outside the org', author: github.login}); + await github.connect(); + const {page} = await openChannel(pw, github.user, github.team.name); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('3'); + + const {patchPluginSettings} = await import('../support/plugin'); + await patchPluginSettings(github.adminClient, {githuborg: 'acme'}); + try { + await page.reload(); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); + const graphql = (await fakeGitHub.requests()).filter((r) => r.path === '/api/graphql').at(-1); + expect(JSON.stringify(graphql?.body)).toContain('org:acme'); + } finally { + await patchPluginSettings(github.adminClient, {githuborg: ''}); + } +}); diff --git a/e2e/integration/tests/smoke.spec.ts b/e2e/integration/tests/smoke.spec.ts new file mode 100644 index 000000000..12e99b4f7 --- /dev/null +++ b/e2e/integration/tests/smoke.spec.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, postWithText, runSlashCommand} from '../support/ui'; + +test('/github me reports the connected GitHub account', async ({pw, github}) => { + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github me'); + + await expect(postWithText(page, 'You are connected to GitHub as')).toContainText(github.login); + expect(await fakeGitHub.calls()).toContain('GET /api/user'); +}); diff --git a/e2e/integration/tests/todo.spec.ts b/e2e/integration/tests/todo.spec.ts new file mode 100644 index 000000000..852a7497f --- /dev/null +++ b/e2e/integration/tests/todo.spec.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, postWithText, runSlashCommand} from '../support/ui'; + +test('/github todo lists unread notifications, review requests, open PRs and assignments', async ({pw, github}) => { + const me = github.login; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createRepo(`${me}/widgets`, {writers: ['octo-author']}); + const review = await fakeGitHub.createItem(`${me}/widgets`, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [me]}); + await fakeGitHub.createItem(`${me}/widgets`, {kind: 'pull', title: 'My own widget PR', author: me}); + await fakeGitHub.createItem(`${me}/widgets`, {kind: 'issue', title: 'Assigned widget bug', author: 'octo-author', assignees: [me]}); + await fakeGitHub.createThread(me, `${me}/widgets`, {number: review.number, reason: 'review_requested'}); + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github todo'); + + const todo = postWithText(page, 'Unread Messages'); + await expect(todo).toContainText('Review Requests'); + await expect(todo).toContainText('Please review the gizmo'); + await expect(todo).toContainText('Your Open Pull Requests'); + await expect(todo).toContainText('My own widget PR'); + await expect(todo).toContainText('Your Assignments'); + await expect(todo).toContainText('Assigned widget bug'); + + const searches = (await fakeGitHub.requests()).filter((r) => r.path === '/api/search/issues').map((r) => r.query.q); + expect(searches).toEqual(expect.arrayContaining([ + expect.stringContaining(`review-requested:${me}`), + expect.stringContaining(`author:${me}`), + expect.stringContaining(`assignee:${me}`), + ])); +}); diff --git a/e2e/integration/tests/ui_layout.spec.ts b/e2e/integration/tests/ui_layout.spec.ts new file mode 100644 index 000000000..00d549c5a --- /dev/null +++ b/e2e/integration/tests/ui_layout.spec.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; + +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {expectInside, expectNoOverlap, expectNothingClipped} from '../support/layout'; +import {labels, longTitle, repo, seedWork as seed} from '../support/seed'; +import {openChannel, openCreateIssueModal, postWithText, sidebarButton, sidebarButtonBar} from '../support/ui'; + +function sidebarButtons(page: Page) { + return (['fa-compress', 'fa-code-fork', 'fa-list-ol', 'fa-envelope'] as const).map((icon) => sidebarButton(page, icon)); +} + +test('the sidebar buttons fit the channel sidebar header without overlapping', async ({pw, github}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + const buttons = sidebarButtons(page); + await expect(buttons[0]).toHaveText('1'); + const sidebar = page.locator('#SidebarContainer'); + + for (const button of buttons) { + await expectInside(button, sidebar); + } + for (let i = 1; i < buttons.length; i++) { + await expectNoOverlap(buttons[i - 1], buttons[i]); + } +}); + +test('a long pull request title and many labels wrap inside the right-hand panel', async ({pw, github}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, 'fa-compress').click(); + const rhs = page.locator('#rhsContainer'); + const title = rhs.getByRole('link', {name: longTitle}); + + await expect(title).toBeVisible(); + await expectInside(title, rhs); + for (const label of labels) { + await expectInside(rhs.getByLabel(label), rhs); + } + await expectNothingClipped(rhs.locator('.scrollbar--view')); +}); + +test('the link tooltip stays inside the window and keeps its content', async ({pw, github}) => { + const pr = await seed(github); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText(longTitle.slice(0, 40)); + + const viewport = page.viewportSize()!; + const box = (await tooltip.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + await expectInside(tooltip.locator('.labels'), tooltip); +}); + +test('a webhook post with labels fits the post', async ({github, pw}) => { + const pr = await seed(github); + await fakeGitHub.createHook('acme', 'widgets', webhookURL, webhookSecret); + await github.userClient.executeCommand(`/github subscriptions add ${repo}`, {channel_id: github.channel.id, team_id: github.team.id}); + await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); + const {page} = await openChannel(pw, github.user, github.team.name); + + const post = postWithText(page, '#new-pull-request'); + await expect(post).toBeVisible(); + await expectInside(post.getByRole('heading', {name: longTitle}), post); + await expectNothingClipped(post.locator('.post-message__text')); +}); + +test.describe('accessibility', () => { + test('the sidebar buttons and right-hand panel have no violations', async ({pw, github, axe}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, 'fa-compress').click(); + await expect(page.locator('#rhsContainer').getByRole('link', {name: longTitle})).toBeVisible(); + await sidebarButtonBar(page).evaluate((el) => el.setAttribute('data-e2e-axe', 'github-sidebar-buttons')); + + const results = await axe.builder(page).include('[data-e2e-axe="github-sidebar-buttons"]').include('#rhsContainer .scrollbar--view').analyze(); + + // Known plugin bug: the icon-only GitHub profile and refresh links have no accessible name. + // Drop this entry once they are labelled; any other violation still fails. + expect(results.violations.flatMap((v) => v.nodes.map((n) => `${v.id}: ${n.html.match(/fa-[\w-]+/)?.[0] ?? n.target}`))).toEqual(['link-name: fa-github', 'link-name: fa-refresh']); + }); + + test('the create issue modal has no violations', async ({pw, github, axe}) => { + await seed(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + await openCreateIssueModal(channelsPage, page); + + const results = await axe.builder(page).include('[role="dialog"]').analyze(); + + // Known plugin bug: the five react-selects and the description textarea have no associated label. + // Drop this entry once they are labelled; any other violation still fails. + const knownUnlabelled = 'label: Form elements must have labels (6)'; + expect(results.violations.map((v) => `${v.id}: ${v.help} (${v.nodes.length})`)).toEqual([knownUnlabelled]); + }); +}); \ No newline at end of file diff --git a/e2e/integration/tests/visual.spec.ts b/e2e/integration/tests/visual.spec.ts new file mode 100644 index 000000000..46f53b81c --- /dev/null +++ b/e2e/integration/tests/visual.spec.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PlaywrightExtended} from '@mattermost/playwright-lib'; +import type {Locator} from '@playwright/test'; + +import type {WebhookRequest} from '../fake-github/webhooks'; +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {longTitle, repo, seedWork} from '../support/seed'; +import {channelPosts, openChannel, openCreateIssueModal, postWithText, replaceText, runSlashCommand, sidebarButton, sidebarButtonBar, waitForPost} from '../support/ui'; + +test.describe('@visual', () => { + // eslint-disable-next-line no-process-env + test.skip(!process.env.PW_VISUAL_BROWSER_WS, 'baselines only match the dockerized browser (npm run test:visual)'); + + // Core UI overlapping the target (avatars, status, timestamps) changes between runs. + async function settle(pw: PlaywrightExtended, target: Locator, {keepHover = false} = {}) { + const page = target.page(); + await expect(page.locator('.LoadingScreen')).toBeHidden(); + await pw.hideDynamicChannelsContent(page); + if (!keepHover) { + await page.mouse.move(0, 0); + } + await page.evaluate(() => document.fonts.ready); + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().endTime !== Infinity).map((a) => a.finished))); + await target.evaluate((el: HTMLElement) => { + const {x, y} = el.getBoundingClientRect(); + el.style.transform = `translate(${Math.round(x) - x}px, ${Math.round(y) - y}px)`; + }); + } + + // Users and commit SHAs are random per run, and "Opened 2 years ago" grows with the calendar. + async function freeze(github: GitHubFixture, target: Locator) { + await replaceText(target, [ + [github.login, 'gh-octocat'], + [`@${github.user.username}`, '@octocat'], + [github.user.username, 'octocat'], + [/\b[0-9a-f]{6,40}\b/g, 'a1b2c3'], + [/\b(a|an|a few|\d+) (seconds?|minutes?|hours?|days?|months?|years?)\b/g, '2 years'], + ]); + } + + async function shoot(pw: PlaywrightExtended, github: GitHubFixture, target: Locator, name: string, options?: {keepHover?: boolean}) { + await expect(target).toBeVisible(); + await freeze(github, target); + await settle(pw, target, options); + await expect(target).toHaveScreenshot(name); + } + + async function rhsTab(pw: PlaywrightExtended, github: GitHubFixture, icon: Parameters[1], title: string) { + await seedWork(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, icon).click(); + const list = page.locator('#rhsContainer .scrollbar--view'); + await expect(list.getByRole('link', {name: title})).toBeVisible(); + return list; + } + + async function webhookPost(pw: PlaywrightExtended, github: GitHubFixture, request: Omit, marker: string) { + await fakeGitHub.createHook('acme', 'widgets', webhookURL, webhookSecret); + await github.userClient.executeCommand(`/github subscriptions add ${repo} --features pulls,issues,issue_comments,pushes`, {channel_id: github.channel.id, team_id: github.team.id}); + await fakeGitHub.webhook({repo, sender: github.login, ...request}); + const post = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.includes(marker), `webhook post containing "${marker}"`); + const opened = await openChannel(pw, github.user, github.team.name); + return {...opened, post, message: opened.page.locator(`#post_${post.id} .post-message__text`)}; + } + + test('sidebar buttons', async ({pw, github}) => { + await seedWork(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('1'); + await shoot(pw, github, sidebarButtonBar(page), 'sidebar-buttons.png'); + }); + + test('open pull requests in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-compress', longTitle), 'rhs-open-prs.png'); + }); + + test('review requests in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-code-fork', 'Please review the gizmo'), 'rhs-review-requests.png'); + }); + + test('assignments with a milestone in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-list-ol', 'Widgets squeak on Mondays'), 'rhs-assignments.png'); + }); + + test('unread messages in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-envelope', 'Unread Messages'), 'rhs-unreads.png'); + }); + + test('pull request link tooltip', async ({pw, github}) => { + const pr = await seedWork(github); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText('Splits polishing into stages.'); + await shoot(pw, github, tooltip, 'tooltip-pull.png', {keepHover: true}); + }); + + test('issue link tooltip', async ({pw, github}) => { + await seedWork(github); + const url = `https://github.com/${repo}/issues/3`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText('Widgets squeak on Mondays'); + await shoot(pw, github, tooltip, 'tooltip-issue.png', {keepHover: true}); + }); + + test('new pull request webhook post', async ({pw, github}) => { + const pr = await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); + await shoot(pw, github, message, 'webhook-new-pull.png'); + }); + + test('merged pull request webhook post', async ({pw, github}) => { + const pr = await seedWork(github); + await fakeGitHub.updateItem(repo, pr.number, {state: 'closed', merged: true}); + const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'closed', number: pr.number}, 'was merged'); + await shoot(pw, github, message, 'webhook-merged-pull.png'); + }); + + test('new issue webhook post', async ({pw, github}) => { + await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'issues', action: 'opened', number: 3}, '#new-issue'); + await shoot(pw, github, message, 'webhook-new-issue.png'); + }); + + test('issue comment webhook post', async ({pw, github}) => { + await seedWork(github); + const comment = 'Oiled them, still squeaky. Next step is replacing the **bearing**:\n\n- order part\n- swap it'; + const {message} = await webhookPost(pw, github, {event: 'issue_comment', action: 'created', number: 3, comment}, 'New comment by'); + await shoot(pw, github, message, 'webhook-issue-comment.png'); + }); + + test('push webhook post', async ({pw, github}) => { + await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'push'}, 'pushed [1 new commit]'); + await shoot(pw, github, message, 'webhook-push.png'); + }); + + test('webhook post in the reply thread', async ({pw, github}) => { + const pr = await seedWork(github); + const {channelsPage, post} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); + await (await channelsPage.centerView.getPostById(post.id)).reply(); + const rhsPost = await channelsPage.sidebarRight.getPostById(post.id); + await shoot(pw, github, rhsPost.container.locator('.post-message__text'), 'reply-thread.png'); + }); + + test('GitHub account in the profile popover', async ({pw, github}) => { + await github.connect(); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Who am I on GitHub?'} as any); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + const popover = await channelsPage.openProfilePopover(await channelsPage.getLastPost()); + const attribute = popover.container.locator('a[href^="https://github.com/"]').locator('xpath=..'); + await shoot(pw, github, attribute, 'profile-popover.png'); + }); + + test('create issue modal', async ({pw, github}) => { + await seedWork(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + const modal = await openCreateIssueModal(channelsPage, page); + await shoot(pw, github, modal.locator('.modal-content'), 'create-issue-modal.png'); + }); + + test('attach to issue modal', async ({pw, github}) => { + await seedWork(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Attach this to an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + const post = await channelsPage.getLastPost(); + await post.hover(); + await post.postMenu.actionsButton.click(); + await page.getByRole('menuitem', {name: 'Attach to GitHub Issue'}).click(); + const modal = page.getByRole('dialog').filter({hasText: 'Attach Message to GitHub Issue'}); + await expect(modal.getByText('Attach this to an issue')).toBeVisible(); + await shoot(pw, github, modal.locator('.modal-content'), 'attach-issue-modal.png'); + }); + + test('/github todo reply', async ({pw, github}) => { + await seedWork(github); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + await runSlashCommand(channelsPage, '/github todo'); + await shoot(pw, github, postWithText(page, 'Unread Messages').locator('.post-message__text'), 'todo.png'); + }); +}); diff --git a/e2e/integration/tests/webhooks.spec.ts b/e2e/integration/tests/webhooks.spec.ts new file mode 100644 index 000000000..e03bef3d9 --- /dev/null +++ b/e2e/integration/tests/webhooks.spec.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {defaultScopes} from '../fake-github/server'; +import {signatures} from '../fake-github/webhooks'; +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {botDMPosts, channelPosts, openChannel, postWithText, runSlashCommand, waitForPost} from '../support/ui'; + +const noWebhookNote = 'No webhook was found for this repository or organization'; +const allFeatures = 'pulls,issues,issue_comments,pushes'; + +async function subscribedRepo(github: GitHubFixture) { + const repo = `${github.login}/widgets`; + await fakeGitHub.createRepo(repo); + await fakeGitHub.createHook(github.login, 'widgets', webhookURL, webhookSecret); + await github.connect(); + await github.userClient.executeCommand(`/github subscriptions add ${repo} --features ${allFeatures}`, {channel_id: github.channel.id, team_id: github.team.id}); + return repo; +} + +function waitForChannelPost(github: GitHubFixture, text: string) { + return waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.includes(text), `channel post containing "${text}"`); +} + +test.describe('subscriptions', () => { + test('warns that the repository has no webhook pointing at this server', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); + await expect(postWithText(page, noWebhookNote)).toContainText('/github setup webhook'); + }); + + test('drops the warning once a webhook points at this server', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await fakeGitHub.createHook(github.login, 'widgets', webhookURL, webhookSecret); + await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); + await expect.poll(() => fakeGitHub.calls()).toContain(`GET /api/repos/${github.login}/widgets/hooks`); + await expect(postWithText(page, noWebhookNote)).toHaveCount(0); + }); + + // Listing repository hooks needs `repo` or `read:repo_hook`; the plugin's public-repo scopes have neither, + // GitHub answers 404, and the plugin treats that as "can't tell" instead of warning. + test('cannot check for a webhook with the default public-repo scopes', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toBeVisible(); + await expect.poll(async () => (await fakeGitHub.requests()).find((r) => r.path === `/api/repos/${github.login}/widgets/hooks`)?.status).toBe(404); + await expect(postWithText(page, noWebhookNote)).toHaveCount(0); + }); +}); + +test.describe('webhook events', () => { + test('a new pull request is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: github.login, body: 'Polishes widgets.', labels: ['enhancement']}); + + const {deliveries} = await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); + + expect(deliveries.map((d) => d.status)).toEqual([200]); + const post = await waitForChannelPost(github, 'Add widget polishing'); + expect(post.message).toContain('#new-pull-request'); + expect(post.message).toContain(`https://github.com/${repo}/pull/${pr.number}`); + }); + + test('a new issue is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets wobble', author: github.login, body: 'They wobble.'}); + + await fakeGitHub.webhook({event: 'issues', action: 'opened', repo, number: issue.number, sender: github.login}); + + const post = await waitForChannelPost(github, 'Widgets wobble'); + expect(post.message).toContain('#new-issue'); + }); + + test('an issue comment is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak', author: github.login}); + + await fakeGitHub.webhook({event: 'issue_comment', action: 'created', repo, number: issue.number, sender: github.login, comment: 'Oiled them, still squeaky.'}); + + const post = await waitForChannelPost(github, 'Oiled them, still squeaky.'); + expect(post.message).toContain('New comment by'); + }); + + test('a push is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + + await fakeGitHub.webhook({event: 'push', repo, sender: github.login}); + + const post = await waitForChannelPost(github, 'Update README'); + expect(post.message).toContain('pushed [1 new commit]'); + expect(post.message).toContain(`${repo}:main`); + }); + + test('a review request DMs the connected reviewer', async ({pw, github}) => { + const repo = await subscribedRepo(github); + const reviewer = await pw.createNewUserProfile(github.adminClient, {prefix: 'reviewer', disableTutorial: true, disableOnboarding: true}); + await github.adminClient.addToTeam(github.team.id, reviewer.id); + const {login: reviewerLogin} = await github.connect(reviewer); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Review my widget', author: github.login, reviewers: [reviewerLogin]}); + + await fakeGitHub.webhook({event: 'pull_request', action: 'review_requested', repo, number: pr.number, sender: github.login, requestedReviewer: reviewerLogin}); + + const dm = await waitForPost(() => botDMPosts(github.adminClient, reviewer.id), (p) => p.message.includes('requested your review'), 'review request DM'); + expect(dm.message).toContain('Review my widget'); + }); +}); + +test.describe('webhook signatures', () => { + async function deliverRaw(github: GitHubFixture, sign: (body: string) => Record) { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Tampered issue', author: github.login}); + const {payload} = await fakeGitHub.webhook({event: 'issues', action: 'opened', repo, number: issue.number, sender: github.login, deliver: false}); + const body = JSON.stringify(payload); + const res = await fetch(webhookURL, {method: 'POST', body, headers: {'Content-Type': 'application/json', 'X-GitHub-Event': 'issues', ...sign(body)}}); + const posts = await channelPosts(github.adminClient, github.channel.id); + return {status: res.status, posted: posts.some((p) => p.message.includes('Tampered issue'))}; + } + + test('a delivery without X-Hub-Signature is rejected', async ({github}) => { + expect(await deliverRaw(github, () => ({}))).toEqual({status: 401, posted: false}); + }); + + test('a delivery signed with another secret is rejected', async ({github}) => { + expect(await deliverRaw(github, (body) => signatures('not-the-webhook-secret', body))).toEqual({status: 401, posted: false}); + }); + + test('a delivery signed with the right secret is accepted', async ({github}) => { + expect(await deliverRaw(github, (body) => signatures(webhookSecret, body))).toEqual({status: 200, posted: true}); + }); +}); diff --git a/e2e/integration/tsconfig.json b/e2e/integration/tsconfig.json new file mode 100644 index 000000000..980db1313 --- /dev/null +++ b/e2e/integration/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["es2022", "dom"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/e2e/playwright/mock_oauth_server/mock_oauth_server.ts b/e2e/playwright/mock_oauth_server/mock_oauth_server.ts index 1d9456cd0..b5d7c5f3e 100644 --- a/e2e/playwright/mock_oauth_server/mock_oauth_server.ts +++ b/e2e/playwright/mock_oauth_server/mock_oauth_server.ts @@ -49,5 +49,34 @@ export const makeOAuthServer = ({ app.use(authorizeURLPrefix, oauthRouter); + // E2E builds also send REST, GraphQL and upload calls here; this suite expects them to reach GitHub. + app.use('/api', forwardTo('https://api.github.com')); + app.use('/uploads', forwardTo('https://uploads.github.com')); + return app; }; + +const hopByHopHeaders = ['host', 'connection', 'content-length', 'content-encoding', 'transfer-encoding']; + +function forwardTo(target: string): express.RequestHandler[] { + return [express.raw({type: () => true, limit: '25mb'}), async (req, res) => { + const headers = Object.fromEntries(Object.entries(req.headers).filter(([name]) => !hopByHopHeaders.includes(name))) as Record; + const hasBody = Buffer.isBuffer(req.body) && req.body.length > 0; + + // Express 4 doesn't catch rejected handlers; an unreachable GitHub would otherwise crash the mock. + let upstream: Awaited>; + try { + upstream = await fetch(target + req.url, {method: req.method, headers, body: hasBody ? req.body : undefined}); + } catch (err) { + res.status(502).send(`mock OAuth server could not reach ${target}: ${err}`); + return; + } + + upstream.headers.forEach((value, name) => { + if (!hopByHopHeaders.includes(name)) { + res.setHeader(name, value); + } + }); + res.status(upstream.status).send(Buffer.from(await upstream.arrayBuffer())); + }]; +} diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go index a606dcb8b..e56d4917d 100644 --- a/server/plugin/graphql/client.go +++ b/server/plugin/graphql/client.go @@ -25,35 +25,34 @@ type Client struct { // NewClient creates and returns Client. The third party package that queries GraphQL is initialized here. func NewClient(logger pluginapi.LogService, getOrganizations func() []string, token oauth2.Token, username, orgName, enterpriseBaseURL string) *Client { - ts := oauth2.StaticTokenSource(&token) - httpClient := oauth2.NewClient(context.Background(), ts) - var client Client - if enterpriseBaseURL == "" { - client = Client{ + return &Client{ username: username, - client: githubv4.NewClient(httpClient), + client: githubv4.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&token))), logger: logger, org: orgName, getOrganizations: getOrganizations, } - } else { - baseURL, err := url.JoinPath(enterpriseBaseURL, "api", "graphql") - if err != nil { - logger.Debug("Not able to parse the enterprise URL", "error", err.Error()) - return nil - } + } - client = Client{ - client: githubv4.NewEnterpriseClient(baseURL, httpClient), - username: username, - org: orgName, - logger: logger, - getOrganizations: getOrganizations, - } + baseURL, err := url.JoinPath(enterpriseBaseURL, "api", "graphql") + if err != nil { + logger.Debug("Not able to parse the enterprise URL", "error", err.Error()) + return nil } - return &client + return NewClientWithURL(logger, getOrganizations, token, username, orgName, baseURL) +} + +// NewClientWithURL creates a Client for the GraphQL endpoint at graphQLURL. +func NewClientWithURL(logger pluginapi.LogService, getOrganizations func() []string, token oauth2.Token, username, orgName, graphQLURL string) *Client { + return &Client{ + client: githubv4.NewEnterpriseClient(graphQLURL, oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&token))), + username: username, + org: orgName, + logger: logger, + getOrganizations: getOrganizations, + } } // executeQuery takes a query struct and sends it to Github GraphQL API via helper package. diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 079bfd172..a306ef48f 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -63,7 +63,8 @@ const ( invalidTokenError = "401 Bad credentials" //#nosec G101 -- False positive ) -// testOAuthServerURL is the URL for the oauthServer used for testing purposes +// testOAuthServerURL is the URL of a fake GitHub used for testing purposes. When set, it replaces +// github.com for OAuth, api.github.com (under /api) and uploads.github.com (under /uploads). // It should be set through ldflags when compiling for E2E, and keep it blank otherwise var testOAuthServerURL = "" @@ -181,6 +182,9 @@ func (p *Plugin) githubConnectUser(_ context.Context, info *GitHubUserInfo) *git func (p *Plugin) graphQLConnect(info *GitHubUserInfo) *graphql.Client { conf := p.getConfiguration() + if testOAuthServerURL != "" && conf.EnterpriseBaseURL == "" { + return graphql.NewClientWithURL(p.client.Log, p.configuration.getOrganizations, *info.Token, info.GitHubUsername, conf.GitHubOrg, testOAuthServerURL+"/api/graphql") + } return graphql.NewClient(p.client.Log, p.configuration.getOrganizations, *info.Token, info.GitHubUsername, conf.GitHubOrg, conf.EnterpriseBaseURL) } @@ -205,7 +209,17 @@ func GetGitHubClient(token oauth2.Token, config *Configuration) (*github.Client, func getGitHubClient(authenticatedClient *http.Client, config *Configuration) (*github.Client, error) { if config.EnterpriseBaseURL == "" || config.EnterpriseUploadURL == "" { - return github.NewClient(authenticatedClient), nil + client := github.NewClient(authenticatedClient) + if testOAuthServerURL != "" { + var err error + if client.BaseURL, err = url.Parse(testOAuthServerURL + "/api/"); err != nil { + return nil, err + } + if client.UploadURL, err = url.Parse(testOAuthServerURL + "/uploads/"); err != nil { + return nil, err + } + } + return client, nil } baseURL, err := url.JoinPath(config.EnterpriseBaseURL, "api", "v3") if err != nil { @@ -251,6 +265,10 @@ func (p *Plugin) setDefaultConfiguration() error { func (p *Plugin) OnActivate() error { p.ensurePluginAPIClient() + if testOAuthServerURL != "" { + p.client.Log.Warn("GitHub OAuth, REST and GraphQL endpoints are overridden; this build is for E2E testing only", "url", testOAuthServerURL) + } + _, err := getSiteURL(p.client) if err != nil { return err diff --git a/server/plugin/plugin_test.go b/server/plugin/plugin_test.go index 8b565d07d..2c1afbb66 100644 --- a/server/plugin/plugin_test.go +++ b/server/plugin/plugin_test.go @@ -625,3 +625,18 @@ func TestUseGitHubClient_AuthFailureNotifiesUser(t *testing.T) { }) } } + +func TestGetGitHubClientWithTestServerURL(t *testing.T) { + original := testOAuthServerURL + testOAuthServerURL = "http://fake-github:8093" + t.Cleanup(func() { testOAuthServerURL = original }) + + client, err := getGitHubClient(http.DefaultClient, &Configuration{}) + require.NoError(t, err) + require.Equal(t, "http://fake-github:8093/api/", client.BaseURL.String()) + require.Equal(t, "http://fake-github:8093/uploads/", client.UploadURL.String()) + + enterprise, err := getGitHubClient(http.DefaultClient, &Configuration{EnterpriseBaseURL: "https://ghe.example.com", EnterpriseUploadURL: "https://ghe.example.com"}) + require.NoError(t, err) + require.Equal(t, "https://ghe.example.com/api/v3/", enterprise.BaseURL.String()) +} diff --git a/webapp/src/components/github_repo_selector/github_repo_selector.jsx b/webapp/src/components/github_repo_selector/github_repo_selector.jsx index 7ad3fd393..87ecc4022 100644 --- a/webapp/src/components/github_repo_selector/github_repo_selector.jsx +++ b/webapp/src/components/github_repo_selector/github_repo_selector.jsx @@ -63,12 +63,14 @@ export default class GithubRepoSelector extends PureComponent { if (prevProps.yourOrgs !== this.props.yourOrgs && this.props.yourOrgs.length > 0) { const newOrg = this.props.yourOrgs[0].login; if (this.state.org !== newOrg) { - this.onChangeForOrg(newOrg); + this.selectOrg(newOrg); } } } - onChangeForOrg = (org) => { + onChangeForOrg = (_, org) => this.selectOrg(org); + + selectOrg = (org) => { if (this.state.org !== org) { this.setState({org}); this.props.actions.getReposByOrg(org, this.props.currentChannelId); From 269f6f8527d75bb101ba3e0c6540e6c6c786c22e Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Mon, 2 Nov 2026 15:37:34 +0700 Subject: [PATCH 2/3] MM-71467 Address review: scope real-lane secrets, install deps for drift check, tolerate non-JSON GitHub errors Co-authored-by: Cursor --- .github/workflows/e2e.yml | 21 ++++++++++++------- .../fake-github/contract/validator.ts | 5 +++-- e2e/integration/real-github/api.ts | 15 +++++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f09994798..54c611613 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -142,6 +142,12 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: .nvmrc + cache: npm + cache-dependency-path: e2e/integration/package-lock.json + + - name: Install e2e dependencies + working-directory: e2e/integration + run: npm ci - name: Compare committed specs with GitHub's published REST description and GraphQL schema working-directory: e2e/integration @@ -158,13 +164,6 @@ jobs: cancel-in-progress: false env: CI: true - E2E_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_ID }} - E2E_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_SECRET }} - E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} - E2E_GITHUB_MACHINE_TOKEN: ${{ secrets.E2E_GITHUB_MACHINE_TOKEN }} - E2E_GITHUB_LOGIN_USERNAME: ${{ secrets.E2E_GITHUB_LOGIN_USERNAME }} - E2E_GITHUB_LOGIN_PASSWORD: ${{ secrets.E2E_GITHUB_LOGIN_PASSWORD }} - E2E_GITHUB_LOGIN_TOTP_SECRET: ${{ secrets.E2E_GITHUB_LOGIN_TOTP_SECRET }} steps: - uses: actions/checkout@v4 with: @@ -202,3 +201,11 @@ jobs: - name: Run journey working-directory: e2e/integration run: npm run test:real + env: + E2E_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_ID }} + E2E_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.E2E_GITHUB_OAUTH_CLIENT_SECRET }} + E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} + E2E_GITHUB_MACHINE_TOKEN: ${{ secrets.E2E_GITHUB_MACHINE_TOKEN }} + E2E_GITHUB_LOGIN_USERNAME: ${{ secrets.E2E_GITHUB_LOGIN_USERNAME }} + E2E_GITHUB_LOGIN_PASSWORD: ${{ secrets.E2E_GITHUB_LOGIN_PASSWORD }} + E2E_GITHUB_LOGIN_TOTP_SECRET: ${{ secrets.E2E_GITHUB_LOGIN_TOTP_SECRET }} diff --git a/e2e/integration/fake-github/contract/validator.ts b/e2e/integration/fake-github/contract/validator.ts index b5f0b3ca1..e21e3961b 100644 --- a/e2e/integration/fake-github/contract/validator.ts +++ b/e2e/integration/fake-github/contract/validator.ts @@ -30,7 +30,7 @@ for (const format of ['uri-template', 'repo.nwo', 'ssh-key', 'ssh-key fingerprin } ajv.addSchema({components: fromOpenAPI(rest.components)}, 'rest'); -type Operation = {method: string; template: string; pattern: RegExp; request?: ValidateFunction; responses: Map}; +type Operation = {method: string; template: string; pattern: RegExp; request?: ValidateFunction; responses: Map; documented: Set}; const operations: Operation[] = Object.entries(rest.paths as JsonObject).flatMap(([template, methods]) => Object.entries(methods as JsonObject).map(([method, op]) => ({ method: method.toUpperCase(), @@ -41,6 +41,7 @@ const operations: Operation[] = Object.entries(rest.paths as JsonObject).flatMap const schema = jsonSchema(resolve(response)); return schema ? [[status, ajv.compile(fromOpenAPI(schema))]] : []; })), + documented: new Set(Object.keys(op.responses as JsonObject)), }))); const webhooks = new Map(Object.entries(rest['x-webhooks'] as JsonObject).map(([name, hook]) => [name, ajv.compile(fromOpenAPI(jsonSchema(hook.post.requestBody)))])); @@ -65,7 +66,7 @@ export function validateResponse(method: string, pathname: string, status: numbe } const validator = op.responses.get(String(status)) ?? apiWide.get(String(status)); if (!validator) { - if (op.responses.has(String(status)) || status === 204 || status === 304) { + if (op.documented.has(String(status)) || status === 204 || status === 304) { return []; } return [`${method} ${op.template}: GitHub does not document a ${status} response`]; diff --git a/e2e/integration/real-github/api.ts b/e2e/integration/real-github/api.ts index 6c8de42c9..f06642a09 100644 --- a/e2e/integration/real-github/api.ts +++ b/e2e/integration/real-github/api.ts @@ -35,8 +35,19 @@ export async function gh(method: string, path: string, {body, token = r continue; } } - const text = await res.text(); - return {status: res.status, body: (text ? JSON.parse(text) : undefined) as T, headers: res.headers}; + return {status: res.status, body: parseBody(await res.text()) as T, headers: res.headers}; + } +} + +// GitHub's 5xx pages are HTML or plain text; keep them so callers can still report the status. +function parseBody(text: string): unknown { + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return text; } } From 7b5e26151180f4bf5b51f7f0ffc7ab2b3f8aabda Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 3 Nov 2026 20:09:02 +0700 Subject: [PATCH 3/3] align specs with conventions --- e2e/integration/README.md | 27 ++- e2e/integration/real-github/journey.spec.ts | 76 ++++++- e2e/integration/scripts/visual.sh | 1 + e2e/integration/tests/connect.spec.ts | 72 +++++-- e2e/integration/tests/contract.spec.ts | 132 ++++++++++-- e2e/integration/tests/issues.spec.ts | 50 ++++- e2e/integration/tests/sidebar.spec.ts | 31 ++- e2e/integration/tests/smoke.spec.ts | 7 +- e2e/integration/tests/todo.spec.ts | 8 +- e2e/integration/tests/ui_layout.spec.ts | 57 ++++- e2e/integration/tests/visual.spec.ts | 204 ++++++++++++++++-- e2e/integration/tests/webhooks.spec.ts | 98 +++++++-- server/plugin/graphql/client_test.go | 63 ++++++ .../github_repo_selector.test.jsx | 53 +++++ 14 files changed, 776 insertions(+), 103 deletions(-) create mode 100644 server/plugin/graphql/client_test.go create mode 100644 webapp/src/components/github_repo_selector/github_repo_selector.test.jsx diff --git a/e2e/integration/README.md b/e2e/integration/README.md index 4f340569e..b5366d608 100644 --- a/e2e/integration/README.md +++ b/e2e/integration/README.md @@ -14,14 +14,35 @@ make e2e-real # normal bundle, against github.com. Needs e2e/integration/.e `make e2e-dist` bakes `http://fake-github:8093` into `testOAuthServerURL`, which now moves the OAuth, REST, GraphQL and upload base URLs together, so the github.com code path is what gets tested. The plugin logs a warning on activation. Never ship that bundle. -Overrides go in the shell or in `e2e/integration/.env` (copy `.env.example`; docker compose reads it too). Real values live in 1Password or GitHub Actions secrets. +## Environment variables + +Set them in the shell or in `e2e/integration/.env` (copy `.env.example`; docker compose reads it too). Real values live in 1Password (search "GitHub plugin E2E") and GitHub Actions secrets; never commit or paste them. + +### Fake lane + +None are required. | Variable | Default | Purpose | |---|---|---| | `MM_IMAGE` | `mattermostdevelopment/mattermost-enterprise-edition:master` | Server image | | `MM_PORT` | `8069` | Host port; must match between `e2e-env-up` and `e2e` | +| `PW_BASE_URL` | `http://127.0.0.1:${MM_PORT}` | Mattermost URL the tests use; leave unset unless the server runs elsewhere | | `PW_BROWSER_PORT` | `3336` | Host port of the dockerized visual browser | | `PW_HEADLESS` | `true` | `false` to watch the browser | +| `PW_SLOWMO` | `0` | Delay between browser actions in ms | + +`npm run test:visual` sets its own `MM_IMAGE`, `MM_PORT` (8077) and `PW_BROWSER_PORT` (3337); override the ports with `VISUAL_MM_PORT` and `VISUAL_BROWSER_PORT`. + +### Real lane + +| Variable | Required | Purpose | +|---|---|---| +| `E2E_GITHUB_OAUTH_CLIENT_ID`, `E2E_GITHUB_OAUTH_CLIENT_SECRET` | Yes | Classic OAuth app whose callback URL is `http://127.0.0.1:${MM_PORT}/plugins/github/oauth/complete` | +| `E2E_GITHUB_REPO` | Yes | `owner/name` of a throwaway repository in a test organization; the machine account needs admin on it | +| `E2E_GITHUB_MACHINE_TOKEN` | Yes | OAuth token or classic PAT of the machine account. Scopes: `repo`, `notifications`, `read:org`, `admin:org_hook`, `admin:repo_hook` | +| `E2E_GITHUB_LOGIN_USERNAME`, `E2E_GITHUB_LOGIN_PASSWORD`, `E2E_GITHUB_LOGIN_TOTP_SECRET` | No | Browser sign-in test; skipped without them. The account needs 2FA with an authenticator app | + +In GitHub Actions these are repository secrets with the same names. The nightly job also needs the variable `REAL_GITHUB_E2E_ENABLED=true`. ## How GitHub is faked @@ -57,3 +78,7 @@ Writes go through `real-github/rate_limit.ts`, which stays under GitHub's conten ## Screenshots `npm run test:visual` compares `@visual` shots in a dockerized browser against a pinned server image, in its own throwaway stack, and does not fail the PR job. Update baselines with `npm run test:visual-update-snapshots` and commit the PNGs. + +## Writing tests + +Tests follow the [Mattermost Playwright guidelines](https://github.com/mattermost/mattermost/blob/master/e2e-tests/playwright/CLAUDE.md): a JSDoc `@objective` (and `@precondition` when setup is unusual), an action-oriented title, the `@github_plugin` tag, and `// #` action / `// *` verification comments. Prefer page objects and `getByRole`/`getByText` locators over CSS. diff --git a/e2e/integration/real-github/journey.spec.ts b/e2e/integration/real-github/journey.spec.ts index 1e5c5ecf9..87e52931f 100644 --- a/e2e/integration/real-github/journey.spec.ts +++ b/e2e/integration/real-github/journey.spec.ts @@ -48,71 +48,122 @@ async function replayDelivery(real: RealGitHubFixture, event: string, match: (pa } test.describe.serial('real GitHub with a KV-seeded machine token', () => { - test('GitHub still behaves the way fake-github models it', async () => { + /** + * @objective Verify real GitHub's hook scopes, GraphQL handling, token errors and REST responses still match what fake-github models. + * + * @precondition + * Needs the real-lane E2E_GITHUB_* env vars and the fixture issue and pull request front-loaded by global setup. + */ + test('confirms real GitHub API behavior still matches the fake-github model', {tag: '@github_plugin'}, async () => { + // # Read the scopes GitHub accepts for repo hooks const hooks = await gh('GET', `/repos/${fullName}/hooks`); const accepted = (hooks.headers.get('x-accepted-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean); + + // * Verify public_repo is not enough to see repo hooks expect.soft(accepted, 'fake-github answers repo hooks with 404 unless the token has one of these scopes').toEqual(expect.arrayContaining(['admin:repo_hook', 'read:repo_hook', 'repo', 'write:repo_hook'])); expect.soft(accepted).not.toContain('public_repo'); + // # Send a GraphQL search with overlapping Issue/PullRequest fields const query = `{search(type: ISSUE, first: 1, query: "repo:${fullName} label:e2e-fixture") {nodes {... on Issue {state} ... on PullRequest {state}}}}`; + + // * Verify both the validator and GitHub accept the query expect(validateGraphQLQuery(query).errors, 'the validator lets this shape conflict through').toEqual([]); const graphql = await ghOK<{data?: unknown; errors?: unknown[]}>('POST', '/graphql', {body: {query}}); expect.soft(graphql.errors, 'GitHub answers overlapping Issue/PullRequest state fields').toBeUndefined(); + // # Exchange a bogus OAuth code const token = await fetch(`${realGitHub.webURL}/login/oauth/access_token`, { method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({client_id: realGitHub.oauth.clientId, client_secret: realGitHub.oauth.clientSecret, code: 'not-a-real-code'}), }); + + // * Verify the error comes back with HTTP 200 expect.soft(token.status, 'token errors come back as HTTP 200').toBe(200); expect.soft(await token.json()).toMatchObject({error: 'bad_verification_code'}); + // * Verify real REST responses match the committed spec const {issue, pull} = readState(); for (const path of ['/user', `/repos/${fullName}`, `/repos/${fullName}/issues/${issue.number}`, `/repos/${fullName}/pulls/${pull.number}`, `/repos/${fullName}/labels`]) { expect.soft(validateResponse('GET', path, 200, await ghOK('GET', path)), `real ${path} matches the committed spec`).toEqual([]); } }); - test('the sidebar and tooltip show the front-loaded issue and pull request', async ({pw, real}) => { + /** + * @objective Verify the sidebar lists and link tooltip show the front-loaded real issue and pull request. + * + * @precondition + * Needs the real-lane E2E_GITHUB_* env vars and the fixture issue and pull request front-loaded by global setup. + */ + test('shows the front-loaded issue and pull request in the sidebar and link tooltip', {tag: '@github_plugin'}, async ({pw, real}) => { + // # Connect with the machine token and run /github me real.connectMachine(); const {page, channelsPage} = await openChannel(pw, real.user, real.team.name); - await runSlashCommand(channelsPage, '/github me'); + + // * Verify the connected GitHub login is shown await expect(page.getByText(real.state.login).last()).toBeVisible(); + // # Open the pull requests list const rhs = page.locator('#rhsContainer'); await sidebarButton(page, 'fa-compress').click(); + + // * Verify the fixture pull request is listed await expect(rhs.getByRole('link', {name: real.state.pull.title})).toBeVisible(); + + // # Open the assignments list await sidebarButton(page, 'fa-list-ol').click(); + + // * Verify the fixture issue is listed await expect(rhs.getByRole('link', {name: real.state.issue.title})).toBeVisible(); + // # Post the fixture issue link and hover it const url = `${realGitHub.webURL}/${fullName}/issues/${real.state.issue.number}`; await real.userClient.createPost({channel_id: real.channel.id, message: url} as any); await page.getByRole('link', {name: url}).last().hover(); + + // * Verify the tooltip shows the issue title await expect(page.locator('.github-tooltip .box').first()).toContainText(real.state.issue.title); }); - test('an issue created from a post reaches GitHub, and its webhook posts back to the channel', async ({pw, real}) => { + /** + * @objective Verify an issue created from a post lands on real GitHub and its replayed issues webhook posts back to the channel. + * + * @precondition + * Needs the real-lane E2E_GITHUB_* env vars and the repository hook created by global setup. + */ + test('creates GitHub issue from a post on real GitHub and posts its webhook back to the channel', {tag: '@github_plugin'}, async ({pw, real}) => { + // # Connect, subscribe the channel to issues and post a message real.connectMachine(); const title = `E2E journey ${Date.now()}`; await real.userClient.executeCommand(`/github subscriptions add ${fullName} --features issues`, {channel_id: real.channel.id, team_id: real.team.id}); await real.userClient.createPost({channel_id: real.channel.id, message: 'File this from Mattermost'} as any); const {channelsPage, page} = await openChannel(pw, real.user, real.team.name); + // # Open the create issue modal const modal = await openCreateIssueModal(channelsPage, page); + + // * Verify the organization defaults to the repository owner await expect(selectField(modal, 'Organization')).toContainText(realGitHub.org); + + // # Pick the repository, fill in the title and submit await chooseOption(page, selectField(modal, 'Repository'), realGitHub.repo); await modal.getByLabel('Title for the GitHub Issue').fill(title); await reserveWrite(); await modal.getByRole('button', {name: 'Submit'}).click(); - await expect(modal).toBeHidden(); + // * Verify the modal closes and the plugin replies with the issue link + await expect(modal).toBeHidden(); const reply = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); const number = Number(/\/issues\/(\d+)/.exec(reply.message)?.[1]); try { + // * Verify the issue exists on GitHub with the title and author expect(await ghOK('GET', `/repos/${fullName}/issues/${number}`)).toMatchObject({title, user: {login: real.state.login}}); + // # Replay the recorded issues.opened delivery to the plugin const {status, payload} = await replayDelivery(real, 'issues', (p) => p.action === 'opened' && p.issue?.number === number); + + // * Verify the payload matches the schema and the plugin posts it to the channel expect.soft(validateWebhook('issues', payload), 'the real payload matches the schema fake-github validates against').toEqual([]); expect(status).toBe(200); const post = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.includes('#new-issue') && p.message.includes(title), 'issues.opened post'); @@ -126,22 +177,31 @@ test.describe.serial('real GitHub with a KV-seeded machine token', () => { test.describe('real GitHub sign-in', () => { test.skip(!canSignInWithPassword(), 'needs E2E_GITHUB_LOGIN_USERNAME, _PASSWORD and _TOTP_SECRET'); - test('a user connects through github.com with password and TOTP', async ({pw, real}) => { + /** + * @objective Verify a user connects through the real github.com sign-in and the plugin requests only the public scopes. + * + * @precondition + * Needs the real-lane E2E_GITHUB_* env vars plus E2E_GITHUB_LOGIN_USERNAME, _PASSWORD and _TOTP_SECRET; skipped otherwise. + */ + test('connects GitHub account through github.com sign-in with password and TOTP', {tag: '@github_plugin'}, async ({pw, real}) => { + // # Start the OAuth connect flow and sign in to github.com const {page} = await openChannel(pw, real.user, real.team.name); const complete = `${mattermostURL}/plugins/github/oauth/complete`; - await page.goto(`${mattermostURL}/plugins/github/oauth/connect`); await completeGitHubSignIn(page, () => page.url().startsWith(complete)); - await expect(page.getByText(/Completed connecting to GitHub/i)).toBeVisible(); + // * Verify the connected page and the stored GitHub username + await expect(page.getByText(/Completed connecting to GitHub/i)).toBeVisible(); const info = readUserInfo(real.user.id); expect(info?.GitHubUsername.toLowerCase()).toBe(realGitHub.login.username!.toLowerCase()); const token = info!.Token.access_token; try { + // * Verify the token carries only the default scopes const me = await gh('GET', '/user', {token}); const scopes = (me.headers.get('x-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean).sort(); expect(scopes, 'the plugin asks for these scopes without /github connect private').toEqual(['admin:org_hook', 'notifications', 'public_repo', 'read:org']); + // * Verify the token cannot see repo hooks const hooks = await gh('GET', `/repos/${fullName}/hooks`, {token}); expect.soft(hooks.status, 'fake-github hides repo hooks from public_repo tokens, so /github subscriptions add always notes a missing webhook').toBe(404); } finally { diff --git a/e2e/integration/scripts/visual.sh b/e2e/integration/scripts/visual.sh index 2d61420e8..c89c11e84 100755 --- a/e2e/integration/scripts/visual.sh +++ b/e2e/integration/scripts/visual.sh @@ -10,6 +10,7 @@ export MM_IMAGE=mattermost/mattermost-enterprise-edition:12.0.1 # A separate, throwaway stack: the pinned server must not start on a database another image already migrated. export COMPOSE_PROJECT_NAME=github-plugin-e2e-visual export MM_PORT="${VISUAL_MM_PORT:-8077}" +export PW_BASE_URL="http://127.0.0.1:${MM_PORT}" export PW_BROWSER_PORT="${VISUAL_BROWSER_PORT:-3337}" trap 'docker compose --profile visual down -v >/dev/null 2>&1' EXIT diff --git a/e2e/integration/tests/connect.spec.ts b/e2e/integration/tests/connect.spec.ts index 1322783a3..de7b8ed0e 100644 --- a/e2e/integration/tests/connect.spec.ts +++ b/e2e/integration/tests/connect.spec.ts @@ -23,87 +23,117 @@ async function startConnect(page: Page, channelsPage: Parameters { - test('connects through GitHub\'s consent page and stores the classic token', async ({pw, github}) => { + /** + * @objective Verify /github connect goes through GitHub's consent page, stores the classic token and sends the welcome DM. + */ + test('connects GitHub account through the OAuth consent page and stores the classic token', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Sign in to GitHub and start /github connect await fakeGitHub.signIn(github.login); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); - const {popup, complete} = await startConnect(page, channelsPage); + + // * Verify the consent page lists the default scopes await expect(popup.getByRole('heading', {name: 'Authorize Mattermost E2E'})).toBeVisible(); await expect(popup.getByRole('listitem')).toHaveText(defaultScopes.slice().sort()); + // # Authorize the app await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); - expect((await complete).status()).toBe(200); + // * Verify the callback succeeds and the classic token is stored + expect((await complete).status()).toBe(200); const stored = readUserInfo(github.user.id); expect(stored).toMatchObject({GitHubUsername: github.login, AllowedPrivateRepos: false, MM34646ResetTokenDone: true, Token: {token_type: 'bearer'}}); expect(stored!.Token.access_token).toMatch(/^gho_/); + // * Verify the welcome DM is sent and the expected GitHub calls were made await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_welcome', 'welcome DM'); expect(await fakeGitHub.calls()).toEqual(expect.arrayContaining(['GET /login/oauth/authorize', 'POST /login/oauth/access_token', 'GET /api/user'])); }); - test('skips the consent page when the app was already authorized for these scopes', async ({pw, github}) => { + /** + * @objective Verify /github connect completes without the consent page when the app already holds the requested scopes. + */ + test('skips the OAuth consent page when the app was already authorized for the requested scopes', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Pre-authorize the app and start /github connect await fakeGitHub.signIn(github.login); await fakeGitHub.grant(github.login, defaultScopes); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); - const {complete} = await startConnect(page, channelsPage); + // * Verify the user is connected without submitting the consent page expect((await complete).status()).toBe(200); expect(readUserInfo(github.user.id)?.GitHubUsername).toBe(github.login); expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/authorize'); }); // GitHub grants the union of every scope the user ever approved for the app, so an old `repo` grant comes back. - test('rejects a token that carries private repo scope from an earlier authorization', async ({pw, github}) => { + /** + * @objective Verify the plugin refuses a token whose scopes include private repo access left over from an earlier grant. + */ + test('rejects OAuth token that carries private repo scope from an earlier authorization', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Grant the app repo scope earlier, then connect and authorize await fakeGitHub.signIn(github.login); await fakeGitHub.grant(github.login, ['repo']); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); - const {popup, complete} = await startConnect(page, channelsPage); await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); const response = await complete; + // * Verify the callback is rejected, nothing is stored and the user gets a scope mismatch DM expect(response.status()).toBe(403); expect(await response.text()).toContain('OAuth token scope does not match the requested permissions'); expect(readUserInfo(github.user.id)).toBeUndefined(); await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => JSON.stringify(p.props).includes('OAuth token scope does not match'), 'scope mismatch DM'); }); - test('rejects a callback whose state was never issued', async ({pw, github}) => { + /** + * @objective Verify the OAuth callback rejects a state token the plugin never issued and skips the code exchange. + */ + test('rejects OAuth callback whose state token was never issued', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Hit the OAuth callback with a forged state const {page} = await openChannel(pw, github.user, github.team.name); - const response = await page.goto('/plugins/github/oauth/complete?code=0123456789abcdef0123&state=forgedstate0000'); + // * Verify the callback fails and no token exchange happens expect(response?.status()).toBe(400); expect(await response?.text()).toContain('invalid state token'); expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/access_token'); }); // GitHub reports token-exchange errors with HTTP 200 and an error field. - test('fails cleanly when GitHub rejects an expired authorization code', async ({pw, github}) => { + /** + * @objective Verify an expired authorization code fails the OAuth callback without storing a token. + */ + test('fails OAuth connect cleanly when GitHub rejects an expired authorization code', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Make authorization codes expire immediately, then connect and authorize await fakeGitHub.signIn(github.login); await fakeGitHub.setOAuthApp({codeTTLms: 0}); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); - const {popup, complete} = await startConnect(page, channelsPage); await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); const response = await complete; + // * Verify the callback fails and nothing is stored expect(response.status()).toBe(500); expect(await response.text()).toContain('failed to exchange oauth code into token'); expect(readUserInfo(github.user.id)).toBeUndefined(); + + // * Verify GitHub answered the token exchange with HTTP 200 const exchange = (await fakeGitHub.requests()).filter((r) => r.path === '/login/oauth/access_token'); expect(exchange.length).toBeGreaterThan(0); expect(exchange.every((r) => r.status === 200)).toBe(true); }); - test('/github disconnect removes the stored token', async ({pw, github}) => { + /** + * @objective Verify /github disconnect removes the user's stored token and username mapping. + */ + test('removes the stored token when disconnecting through /github disconnect', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Connect, then run /github disconnect await github.connect(); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); - await runSlashCommand(channelsPage, '/github disconnect'); + // * Verify the confirmation post and that the KV entries are gone await expect(postWithText(page, 'Disconnected your GitHub account.')).toBeVisible(); expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); expect(hasKey(`${github.login}_githubusername`)).toBe(false); @@ -111,27 +141,35 @@ test.describe('OAuth connect', () => { }); test.describe('auth failures', () => { - test('a revoked token disconnects the user and DMs them to reconnect', async ({pw, github}) => { + /** + * @objective Verify a GitHub 401 on a revoked token disconnects the user and DMs them to reconnect. + */ + test('disconnects the user and DMs a reconnect prompt when the GitHub token is revoked', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Connect, revoke the token and run /github me const {token} = await github.connect(); await fakeGitHub.revoke(token.value); const {channelsPage} = await openChannel(pw, github.user, github.team.name); - await runSlashCommand(channelsPage, '/github me'); + // * Verify the revoked-token DM, the removed token and the 401 from GitHub await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_revoked_token', 'revoked-token DM'); expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); expect((await fakeGitHub.requests()).some((r) => r.path.startsWith('/api/') && r.status === 401)).toBe(true); }); - test('SAML enforcement keeps the connection and DMs a re-authorize link once', async ({pw, github}) => { + /** + * @objective Verify SAML SSO enforcement keeps the user connected and DMs a single re-authorize link. + */ + test('keeps the connection and DMs one re-authorize link when SAML SSO is enforced', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Create an SSO-enforced org and subscribe to its repo twice await fakeGitHub.createOrg('acme-sso', {members: [github.login], ssoEnforced: true}); await fakeGitHub.createRepo('acme-sso/widgets'); await github.connect(); const {channelsPage} = await openChannel(pw, github.user, github.team.name); - await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); + // * Verify exactly one SAML DM with the re-authorize link and that the user stays connected const dm = await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => p.message.includes('SAML SSO session expired'), 'SAML DM'); expect(dm.message).toContain('https://github.com/orgs/acme-sso/sso?authorization_request='); await expect.poll(async () => (await botDMPosts(github.adminClient, github.user.id)).filter((p) => p.message.includes('SAML SSO session expired')).length).toBe(1); diff --git a/e2e/integration/tests/contract.spec.ts b/e2e/integration/tests/contract.spec.ts index 631cd95ca..5577c6455 100644 --- a/e2e/integration/tests/contract.spec.ts +++ b/e2e/integration/tests/contract.spec.ts @@ -51,73 +51,125 @@ test.describe('contract', () => { expect(await (await fetch(`${base}/__control/contract-violations`)).json()).toEqual([]); }); - test('every REST route the plugin uses answers with a schema-valid body', async () => { + /** + * @objective Verify every REST route the plugin calls answers with a status and body valid against GitHub's schema. + */ + test('answers every plugin REST route with a schema-valid body', {tag: '@github_plugin'}, async () => { + // # Call each read route the plugin uses + // * Verify each answers 200 for (const path of ['/user', '/users/bob', '/user/orgs', '/user/repos', '/users/alice/repos', '/orgs/acme', '/orgs/acme/repos', '/orgs/acme/hooks', '/repos/acme/widgets', '/repos/acme/widgets/issues/2', '/repos/acme/widgets/labels', '/repos/acme/widgets/assignees', '/repos/acme/widgets/milestones', '/repos/acme/widgets/pulls/1', '/repos/acme/widgets/pulls/1/reviews', '/search/issues?q=is%3Apr+review-requested%3Abob', '/notifications']) { expect((await api(path)).status, path).toBe(200); } + + // # Check org membership and create an issue and a comment + // * Verify the membership check and both writes succeed expect((await api('/orgs/acme/members/bob')).status).toBe(204); expect((await api('/repos/acme/widgets/issues', {method: 'POST', body: JSON.stringify({title: 'New', labels: ['bug']})})).status).toBe(201); expect((await api('/repos/acme/widgets/issues/1/comments', {method: 'POST', body: JSON.stringify({body: 'hi'})})).status).toBe(201); }); - test('answers like GitHub for bad credentials, missing scopes and SAML', async () => { + /** + * @objective Verify the fake answers bad credentials, missing scopes and SAML enforcement with GitHub's statuses and headers. + */ + test('rejects bad credentials, missing scopes and SAML-enforced access like GitHub', {tag: '@github_plugin'}, async () => { + // # Call the API with an unknown token const bad = await fetch(`${base}/api/user`, {headers: {Authorization: 'Bearer gho_nope'}}); + + // * Verify it answers 401 Bad credentials expect(bad.status).toBe(401); expect(await bad.json()).toMatchObject({message: 'Bad credentials'}); + + // # Revoke the token and query GraphQL await control('/revoke', {token}); + + // * Verify GraphQL answers 401 expect((await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(type: ISSUE, query: "x", first: 1) { issueCount } }'})})).status).toBe(401); token = (await control('/tokens', {login: 'alice'})).value; + // # Call routes with scopes that do not cover them + // * Verify repo hooks answer 404, orgs answer 403 and the granted scopes are reported expect((await api('/repos/acme/widgets/hooks')).status).toBe(404); await control('/users', {login: 'dave'}); const narrow = (await control('/tokens', {login: 'dave', scopes: ['public_repo']})).value; expect((await fetch(`${base}/api/user/orgs`, {headers: {Authorization: `Bearer ${narrow}`}})).status).toBe(403); expect((await api('/user')).headers.get('x-oauth-scopes')).toBe('admin:org_hook, notifications, public_repo, read:org'); + // # Enforce SAML SSO on the org and read a repo await control('/sso', {org: 'acme', enforced: true}); const saml = await api('/repos/acme/widgets'); + + // * Verify the read answers 403 with the SSO authorization URL expect(saml.status).toBe(403); expect(saml.headers.get('x-github-sso')).toMatch(/^required; url=https:\/\/github\.com\/orgs\/acme\/sso\?authorization_request=/); + + // # Search across the SSO-enforced org const search = await api('/search/issues?q=author%3Aalice'); + + // * Verify search returns partial results with the SSO header expect((await search.json()).total_count).toBe(0); expect(search.headers.get('x-github-sso')).toMatch(/^partial-results; organizations=\d+$/); + // # Read the repo as a user outside the org await control('/users', {login: 'carol'}); const outsider = (await control('/tokens', {login: 'carol'})).value; + + // * Verify the outsider is not blocked by SSO expect((await fetch(`${base}/api/repos/acme/widgets`, {headers: {Authorization: `Bearer ${outsider}`}})).status).toBe(200); }); - test('commenting through the API delivers issue_comment to the repository hook', async () => { + /** + * @objective Verify posting an issue comment through the API delivers an issue_comment event to the repository hook. + */ + test('delivers issue_comment to the repository hook when commenting through the API', {tag: '@github_plugin'}, async () => { + // # Register a repository hook and comment on an issue await control('/hooks', {owner: 'acme', repo: 'widgets', url: `${base}/`, secret: 's'}); expect((await api('/repos/acme/widgets/issues/2/comments', {method: 'POST', body: JSON.stringify({body: 'on it'})})).status).toBe(201); await fetch(`${base}/__control/settle`, {method: 'POST'}); + + // * Verify the hook received exactly that comment const delivered = (await (await fetch(`${base}/__control/requests`)).json()).filter((r: {method: string; path: string; body?: {comment?: {body: string}}}) => r.method === 'POST' && r.path === '/'); expect(delivered.map((r: {body?: {comment?: {body: string}}}) => r.body?.comment?.body)).toEqual(['on it']); }); - test('paginates with Link headers', async () => { + /** + * @objective Verify search results paginate with GitHub-style Link headers. + */ + test('paginates search results with Link headers', {tag: '@github_plugin'}, async () => { + // # Seed enough issues for three pages for (let i = 0; i < 4; i++) { await control('/items', {repo: 'acme/widgets', kind: 'issue', title: `Bulk ${i}`, author: 'alice'}); } + + // # Request the first and last pages const first = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2'); - expect(first.headers.get('link')).toContain('rel="next"'); const last = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2&page=3'); + + // * Verify only the first page links to a next page and the last page holds the remainder + expect(first.headers.get('link')).toContain('rel="next"'); expect(last.headers.get('link')).not.toContain('rel="next"'); expect((await last.json()).items).toHaveLength(2); }); - test('the plugin\'s sidebar and digest queries validate and execute against GitHub\'s schema', async () => { + /** + * @objective Verify the plugin's sidebar and digest GraphQL query validates against GitHub's schema and returns user and team reviewers. + */ + test('validates and executes the sidebar and digest GraphQL query against GitHub\'s schema', {tag: '@github_plugin'}, async () => { const query = `query($q: String!, $c: String) { pullRequest: search(first: 100, after: $c, query: $q, type: ISSUE) { issueCount pageInfo { hasNextPage endCursor } nodes { ... on PullRequest { body number authorAssociation createdAt updatedAt repository { name nameWithOwner url owner { login } } state title author { login } url labels(first: 100) { nodes { name color } } milestone { title } additions deletions changedFiles reviewRequests(first: 100) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug organization { login } } } } } } ... on Issue { number title state url milestone { title } } } } }`; + + // * Verify the query is valid against the schema expect(validateGraphQLQuery(query).errors).toEqual([]); + // # Run the query for bob's review requests const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query, variables: {q: 'review-requested:bob is:pr is:OPEN archived:false org:acme'}})}); const {data} = await res.json(); + + // * Verify it returns the pull request with both user and team reviewers expect(data.pullRequest.issueCount).toBe(1); expect(data.pullRequest.nodes[0].reviewRequests.nodes.map((n: any) => n.requestedReviewer)).toEqual([ {__typename: 'User', login: 'bob'}, @@ -125,24 +177,47 @@ test.describe('contract', () => { ]); }); - test('GraphQL rejects unknown fields and missing pagination bounds like GitHub', async () => { + /** + * @objective Verify GraphQL rejects unknown fields and connections without first or last, as GitHub does. + */ + test('rejects GraphQL queries with unknown fields or missing pagination bounds', {tag: '@github_plugin'}, async () => { + // * Verify an unknown field fails validation expect(validateGraphQLQuery('{ search(first: 1, query: "x", type: ISSUE) { nodes { ... on Issue { nope } } } }').errors).toHaveLength(1); + + // # Run a search without first or last const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(query: "x", type: ISSUE) { issueCount } }'})}); + + // * Verify it answers 200 with GitHub's pagination error expect(res.status).toBe(200); expect((await res.json()).errors[0].message).toContain('You must provide a `first` or `last` value'); }); - test('OAuth token exchange errors come back as HTTP 200, as form or JSON by Accept header', async () => { + /** + * @objective Verify OAuth token exchange errors come back as HTTP 200, encoded as form or JSON depending on the Accept header. + */ + test('returns OAuth token exchange errors as HTTP 200 in form or JSON by Accept header', {tag: '@github_plugin'}, async () => { + // # Exchange a bad code without an Accept header const form = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', body: new URLSearchParams({client_id: app.clientId, client_secret: app.clientSecret, code: 'nope'})}); + + // * Verify a form-encoded bad_verification_code error with status 200 expect(form.status).toBe(200); expect(Object.fromEntries(new URLSearchParams(await form.text()))).toMatchObject({error: 'bad_verification_code'}); + // # Exchange with a wrong client secret asking for JSON const json = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', headers: {Accept: 'application/json'}, body: new URLSearchParams({client_id: app.clientId, client_secret: 'wrong', code: 'nope'})}); + + // * Verify a JSON incorrect_client_credentials error expect(await json.json()).toMatchObject({error: 'incorrect_client_credentials'}); }); - test('webhook payloads match GitHub\'s event schemas and carry both signatures', async () => { + /** + * @objective Verify the fake's webhook payloads match GitHub's event schemas and are signed with both SHA-1 and SHA-256. + */ + test('builds webhook payloads that match GitHub\'s event schemas and carry both signatures', {tag: '@github_plugin'}, async () => { const state = fake.state; + + // # Build a payload for each event the plugin handles + // * Verify each payload validates against its event schema for (const request of [ {event: 'pull_request', action: 'opened', number: 1}, {event: 'pull_request', action: 'review_requested', number: 1, requestedReviewer: 'bob'}, @@ -153,13 +228,19 @@ test.describe('contract', () => { const payload = buildPayload(state, {repo: 'acme/widgets', sender: 'alice', ...request}); expect(validateWebhook(request.event, payload), `${request.event}.${request.action}`).toEqual([]); } + + // * Verify both signature headers are produced expect(signatures('secret', '{}')).toEqual({ 'X-Hub-Signature': expect.stringMatching(/^sha1=[0-9a-f]{40}$/), 'X-Hub-Signature-256': expect.stringMatching(/^sha256=[0-9a-f]{64}$/), }); }); - test('creating a hook sends GitHub\'s ping delivery', async () => { + /** + * @objective Verify creating an org hook sends GitHub's ping delivery to the hook URL. + */ + test('sends a ping delivery when an org hook is created', {tag: '@github_plugin'}, async () => { + // # Start a sink to receive deliveries const received: string[] = []; const {createServer} = await import('node:http'); const sink = createServer((req, res) => { @@ -168,7 +249,10 @@ test.describe('contract', () => { }); await new Promise((resolve) => sink.listen(8194, resolve)); try { + // # Create an org hook pointing at the sink const res = await api('/orgs/acme/hooks', {method: 'POST', body: JSON.stringify({name: 'web', config: {url: 'http://localhost:8194/hook', content_type: 'json', secret: 's'}, events: ['*']})}); + + // * Verify the hook is created and the sink receives a ping expect(res.status).toBe(201); await expect.poll(() => received).toEqual(['ping']); } finally { @@ -178,7 +262,11 @@ test.describe('contract', () => { }); test.describe('fake semantics', () => { - test('search qualifiers are case-insensitive, repeated org: is OR, and @me resolves to the viewer', () => { + /** + * @objective Verify search parsing treats qualifiers case-insensitively, ORs repeated org: and resolves @me to the viewer. + */ + test('parses search qualifiers case-insensitively, ORs repeated org: and resolves @me to the viewer', {tag: '@github_plugin'}, () => { + // * Verify the parsed qualifiers expect(parseSearch('is:pr is:OPEN org:a org:b author:@me "two words"', 'alice')).toEqual([ {qualifier: 'is', values: ['pr'], negated: false}, {qualifier: 'is', values: ['OPEN'], negated: false}, @@ -188,19 +276,33 @@ test.describe('fake semantics', () => { ]); }); - test('scopes covered by a broader grant are folded away', () => { + /** + * @objective Verify scope normalization drops scopes already covered by a broader grant. + */ + test('folds away scopes covered by a broader grant', {tag: '@github_plugin'}, () => { + // * Verify repo absorbs public_repo and admin:org absorbs read:org expect(normalizeScopes(['public_repo', 'repo', 'read:org', 'notifications'])).toEqual(['notifications', 'read:org', 'repo']); expect(normalizeScopes(['read:org', 'admin:org'])).toEqual(['admin:org']); }); - test('skeletons satisfy their own schemas', () => { + /** + * @objective Verify the response and webhook skeletons validate against the schemas they are generated from. + */ + test('validates response and webhook skeletons against their own schemas', {tag: '@github_plugin'}, () => { + // * Verify the user and ping skeletons have no violations expect(validateResponse('GET', '/user', 200, skeleton('private-user'))).toEqual([]); expect(validateWebhook('ping', webhookSkeleton('ping'))).toEqual([]); }); - test('the fake rejects payloads GitHub would never send', () => { - expect(validateResponse('GET', '/user', 200, {login: 'x'})).not.toEqual([]); + /** + * @objective Verify the validator catches responses and payloads GitHub would never send, so a passing check means something. + */ + test('rejects responses and payloads GitHub would never send', {tag: '@github_plugin'}, () => { + // # Build an issues payload with an invalid action const payload = buildPayload(seededState(), {event: 'issues', action: 'opened', repo: 'acme/widgets', number: 1, sender: 'alice'}); + + // * Verify an incomplete user and the invalid action are both reported + expect(validateResponse('GET', '/user', 200, {login: 'x'})).not.toEqual([]); expect(validateWebhook('issues', {...payload, action: 'teleported'})).not.toEqual([]); }); }); diff --git a/e2e/integration/tests/issues.spec.ts b/e2e/integration/tests/issues.spec.ts index 674806d3e..0e62be01a 100644 --- a/e2e/integration/tests/issues.spec.ts +++ b/e2e/integration/tests/issues.spec.ts @@ -14,16 +14,20 @@ async function seedRepo(github: GitHubFixture, options: {canPush?: boolean} = {} } test.describe('link tooltip', () => { - test('shows pull request details for a github.com link', async ({pw, github}) => { + /** + * @objective Verify hovering a github.com pull request link shows its title, number, author, refs and labels. + */ + test('shows pull request details in the link tooltip for a github.com link', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post a pull request link and hover it await seedRepo(github); const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: 'octo-author', labels: ['enhancement'], head: 'polish', base: 'main'}); await github.connect(); const url = `https://github.com/${repo}/pull/${pr.number}`; await github.userClient.createPost({channel_id: github.channel.id, message: `Have a look: ${url}`} as any); const {page} = await openChannel(pw, github.user, github.team.name); - await postWithText(page, 'Have a look').getByRole('link', {name: url}).hover(); + // * Verify the tooltip shows the pull request details fetched from GitHub const tooltip = page.locator('.github-tooltip').first(); await expect(tooltip).toContainText('Add widget polishing'); await expect(tooltip).toContainText(`#${pr.number}`); @@ -33,16 +37,20 @@ test.describe('link tooltip', () => { expect(await fakeGitHub.calls()).toContain(`GET /api/repos/${repo}/pulls/${pr.number}`); }); - test('shows issue details for a github.com link', async ({pw, github}) => { + /** + * @objective Verify hovering a github.com issue link shows its title and body. + */ + test('shows issue details in the link tooltip for a github.com link', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post an issue link and hover it await seedRepo(github); const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', body: 'Every Monday, without fail.'}); await github.connect(); const url = `https://github.com/${repo}/issues/${issue.number}`; await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); const {page} = await openChannel(pw, github.user, github.team.name); - await page.getByRole('link', {name: url}).last().hover(); + // * Verify the tooltip shows the issue title and body const tooltip = page.locator('.github-tooltip').first(); await expect(tooltip).toContainText('Widgets squeak on Mondays'); await expect(tooltip).toContainText('Every Monday, without fail.'); @@ -56,52 +64,74 @@ test.describe('create issue from a post', () => { return {page, modal: await openCreateIssueModal(channelsPage, page)}; } - test('creates the issue with labels, assignee and milestone and links back to the post', async ({pw, github}) => { + /** + * @objective Verify the create issue modal files an issue with labels, assignee and milestone and replies with its link. + */ + test('creates GitHub issue from a post with labels, assignee and milestone and links back to it', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the create issue modal from a post await seedRepo(github); await github.connect(); const {page, modal} = await openCreateIssue(pw, github, 'The widget squeaks when it rains'); + // * Verify the user's account is the default organization await expect(selectField(modal, 'Organization')).toContainText(github.login); + + // # Fill in repository, title, labels, assignee and milestone and submit await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); await modal.getByLabel('Title for the GitHub Issue').fill('Widget squeaks in the rain'); await chooseOption(page, selectField(modal, 'Labels'), 'bug'); await chooseOption(page, selectField(modal, 'Assignees'), github.login); await chooseOption(page, selectField(modal, 'Milestone'), 'v1.0'); await modal.getByRole('button', {name: 'Submit'}).click(); - await expect(modal).toBeHidden(); + // * Verify the modal closes and the issue is created with the chosen attributes + await expect(modal).toBeHidden(); const created = (await fakeGitHub.items()).find((i) => i.title === 'Widget squeaks in the rain'); expect(created).toMatchObject({kind: 'issue', author: github.login, labels: ['bug'], assignees: [github.login], milestone: 1}); expect(created!.body).toContain('The widget squeaks when it rains'); + + // * Verify the reply in the channel links to the new issue const reply = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); expect(reply.message).toContain(`https://github.com/${repo}/issues/${created!.number}`); }); // GitHub silently drops labels, assignees and milestones from users without push access; the modal hides them. - test('hides issue attributes for a repository the user cannot push to', async ({pw, github}) => { + /** + * @objective Verify the create issue modal hides labels and assignees for a repository the user cannot push to. + */ + test('hides issue attributes in the create issue modal for a repository the user cannot push to', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the create issue modal and pick a read-only repository await seedRepo(github, {canPush: false}); await github.connect(); const {page, modal} = await openCreateIssue(pw, github, 'Read-only repo message'); - await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); + // * Verify the labels and assignees fields are hidden await expect(modal.getByText('Labels', {exact: true})).toHaveCount(0); await expect(modal.getByText('Assignees', {exact: true})).toHaveCount(0); + + // # Fill in the title and submit await modal.getByLabel('Title for the GitHub Issue').fill('Filed without push access'); await modal.getByRole('button', {name: 'Submit'}).click(); + + // * Verify the issue is created without labels or assignees await expect(modal).toBeHidden(); expect((await fakeGitHub.items()).find((i) => i.title === 'Filed without push access')).toMatchObject({labels: [], assignees: []}); }); - test('switching organization lists that organization\'s repositories', async ({pw, github}) => { + /** + * @objective Verify switching the organization in the create issue modal loads that organization's repositories. + */ + test('lists the selected organization\'s repositories after switching organization in the create issue modal', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the create issue modal and switch to a second organization await seedRepo(github); await fakeGitHub.createOrg('gizmo-co', {members: [github.login]}); await fakeGitHub.createRepo('gizmo-co/gizmos', {writers: [github.login]}); await github.connect(); const {page, modal} = await openCreateIssue(pw, github, 'Which org?'); - await chooseOption(page, selectField(modal, 'Organization'), 'gizmo-co'); + // * Verify the organization's repositories are fetched and selectable await expect.poll(() => fakeGitHub.calls(), {timeout: 5_000}).toContain('GET /api/orgs/gizmo-co/repos'); await chooseOption(page, selectField(modal, 'Repository'), 'gizmos'); }); diff --git a/e2e/integration/tests/sidebar.spec.ts b/e2e/integration/tests/sidebar.spec.ts index 5d053aedc..20898e045 100644 --- a/e2e/integration/tests/sidebar.spec.ts +++ b/e2e/integration/tests/sidebar.spec.ts @@ -20,18 +20,26 @@ async function seedWork(github: GitHubFixture) { await fakeGitHub.createThread(me, repo, {number: review.number, reason: 'review_requested'}); } -test('sidebar counts and RHS lists come from GitHub search and notifications', async ({pw, github}) => { +/** + * @objective Verify the sidebar counts and RHS lists are populated from GitHub search and notifications. + */ +test('shows sidebar counts and RHS lists from GitHub search and notifications', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed GitHub work, connect and open the channel await seedWork(github); await github.connect(); const {page} = await openChannel(pw, github.user, github.team.name); + // * Verify each sidebar button shows its count await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); await expect(sidebarButton(page, 'fa-code-fork')).toHaveText('1'); await expect(sidebarButton(page, 'fa-list-ol')).toHaveText('1'); await expect(sidebarButton(page, 'fa-envelope')).toHaveText('1'); + // # Open the open pull requests list const rhs = page.locator('#rhsContainer'); await sidebarButton(page, 'fa-compress').click(); + + // * Verify only open pull requests are listed with their details await expect(rhs.getByRole('link', {name: 'Your Open Pull Requests'})).toBeVisible(); await expect(rhs.getByRole('link', {name: 'Add widget polishing'})).toBeVisible(); await expect(rhs.getByRole('link', {name: 'Fix widget wobble'})).toBeVisible(); @@ -39,36 +47,55 @@ test('sidebar counts and RHS lists come from GitHub search and notifications', a await expect(rhs.getByText('3 Files Changed')).toBeVisible(); await expect(rhs.getByLabel('Success')).toHaveCount(1); + // # Open the review requests list await sidebarButton(page, 'fa-code-fork').click(); + + // * Verify the review request is listed await expect(rhs.getByRole('link', {name: 'Pull Requests Needing Review'})).toBeVisible(); await expect(rhs.getByRole('link', {name: 'Please review the gizmo'})).toBeVisible(); + // # Open the assignments list await sidebarButton(page, 'fa-list-ol').click(); + + // * Verify the assigned issue is listed with its milestone await expect(rhs.getByRole('link', {name: 'Your Assignments'})).toBeVisible(); await expect(rhs.getByRole('link', {name: 'Widgets squeak on Mondays'})).toBeVisible(); await expect(rhs.getByRole('note', {name: 'v1.0'})).toBeVisible(); + // # Open the unread messages list await sidebarButton(page, 'fa-envelope').click(); + + // * Verify the review request notification is listed await expect(rhs.getByRole('link', {name: 'Unread Messages'})).toBeVisible(); await expect(rhs.getByText('Please review the gizmo')).toBeVisible(); + // * Verify the data came from GitHub GraphQL, notifications and pull request APIs const calls = await fakeGitHub.calls(); expect(calls).toEqual(expect.arrayContaining(['POST /api/graphql', 'GET /api/notifications', `GET /api/repos/${repo}/pulls/1/reviews`, `GET /api/repos/${repo}/pulls/1`])); }); -test('an org lock limits the sidebar to that organization', async ({pw, github}) => { +/** + * @objective Verify locking the plugin to an organization limits the sidebar counts to that organization. + */ +test('limits sidebar counts to the locked organization', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed a pull request outside the acme org, connect and open the channel await seedWork(github); await fakeGitHub.createOrg('elsewhere', {members: [github.login]}); await fakeGitHub.createRepo('elsewhere/gadgets', {writers: [github.login]}); await fakeGitHub.createItem('elsewhere/gadgets', {kind: 'pull', title: 'Gadget PR outside the org', author: github.login}); await github.connect(); const {page} = await openChannel(pw, github.user, github.team.name); + + // * Verify pull requests from both orgs are counted await expect(sidebarButton(page, 'fa-compress')).toHaveText('3'); + // # Lock the plugin to the acme org and reload const {patchPluginSettings} = await import('../support/plugin'); await patchPluginSettings(github.adminClient, {githuborg: 'acme'}); try { await page.reload(); + + // * Verify only acme pull requests are counted and the search is scoped to the org await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); const graphql = (await fakeGitHub.requests()).filter((r) => r.path === '/api/graphql').at(-1); expect(JSON.stringify(graphql?.body)).toContain('org:acme'); diff --git a/e2e/integration/tests/smoke.spec.ts b/e2e/integration/tests/smoke.spec.ts index 12e99b4f7..4cd426062 100644 --- a/e2e/integration/tests/smoke.spec.ts +++ b/e2e/integration/tests/smoke.spec.ts @@ -5,12 +5,17 @@ import {expect, test} from '../support/fixture'; import {fakeGitHub} from '../support/fake_github'; import {openChannel, postWithText, runSlashCommand} from '../support/ui'; -test('/github me reports the connected GitHub account', async ({pw, github}) => { +/** + * @objective Verify /github me replies with the connected GitHub account fetched from the GitHub API. + */ +test('reports connected GitHub account when running /github me', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Connect GitHub and run /github me await github.connect(); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); await runSlashCommand(channelsPage, '/github me'); + // * Verify the reply names the GitHub login and the user was fetched from GitHub await expect(postWithText(page, 'You are connected to GitHub as')).toContainText(github.login); expect(await fakeGitHub.calls()).toContain('GET /api/user'); }); diff --git a/e2e/integration/tests/todo.spec.ts b/e2e/integration/tests/todo.spec.ts index 852a7497f..08318d74f 100644 --- a/e2e/integration/tests/todo.spec.ts +++ b/e2e/integration/tests/todo.spec.ts @@ -5,7 +5,11 @@ import {expect, test} from '../support/fixture'; import {fakeGitHub} from '../support/fake_github'; import {openChannel, postWithText, runSlashCommand} from '../support/ui'; -test('/github todo lists unread notifications, review requests, open PRs and assignments', async ({pw, github}) => { +/** + * @objective Verify /github todo lists unread notifications, review requests, open pull requests and assignments from GitHub search. + */ +test('lists unread notifications, review requests, open PRs and assignments when running /github todo', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed a review request, an own pull request, an assigned issue and a notification, then run /github todo const me = github.login; await fakeGitHub.createUser('octo-author'); await fakeGitHub.createRepo(`${me}/widgets`, {writers: ['octo-author']}); @@ -18,6 +22,7 @@ test('/github todo lists unread notifications, review requests, open PRs and ass await runSlashCommand(channelsPage, '/github todo'); + // * Verify the reply lists each section with its item const todo = postWithText(page, 'Unread Messages'); await expect(todo).toContainText('Review Requests'); await expect(todo).toContainText('Please review the gizmo'); @@ -26,6 +31,7 @@ test('/github todo lists unread notifications, review requests, open PRs and ass await expect(todo).toContainText('Your Assignments'); await expect(todo).toContainText('Assigned widget bug'); + // * Verify GitHub was searched for review requests, authored items and assignments const searches = (await fakeGitHub.requests()).filter((r) => r.path === '/api/search/issues').map((r) => r.query.q); expect(searches).toEqual(expect.arrayContaining([ expect.stringContaining(`review-requested:${me}`), diff --git a/e2e/integration/tests/ui_layout.spec.ts b/e2e/integration/tests/ui_layout.spec.ts index 00d549c5a..a61ab880d 100644 --- a/e2e/integration/tests/ui_layout.spec.ts +++ b/e2e/integration/tests/ui_layout.spec.ts @@ -14,13 +14,20 @@ function sidebarButtons(page: Page) { return (['fa-compress', 'fa-code-fork', 'fa-list-ol', 'fa-envelope'] as const).map((icon) => sidebarButton(page, icon)); } -test('the sidebar buttons fit the channel sidebar header without overlapping', async ({pw, github}) => { +/** + * @objective Verify the plugin's sidebar buttons stay inside the channel sidebar header and do not overlap each other. + */ +test('fits the sidebar buttons inside the channel sidebar header without overlapping', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed work and open the channel await seed(github); - const {page} = await openChannel(pw, github.user, github.team.name); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); const buttons = sidebarButtons(page); + + // * Verify the buttons have loaded their counts await expect(buttons[0]).toHaveText('1'); - const sidebar = page.locator('#SidebarContainer'); + // * Verify each button sits inside the sidebar and none overlap + const sidebar = channelsPage.sidebarLeft.container; for (const button of buttons) { await expectInside(button, sidebar); } @@ -29,13 +36,18 @@ test('the sidebar buttons fit the channel sidebar header without overlapping', a } }); -test('a long pull request title and many labels wrap inside the right-hand panel', async ({pw, github}) => { +/** + * @objective Verify a long pull request title and many labels wrap inside the right-hand panel without clipping. + */ +test('wraps a long pull request title and many labels inside the right-hand panel', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed work and open the pull requests list in the right-hand panel await seed(github); const {page} = await openChannel(pw, github.user, github.team.name); await sidebarButton(page, 'fa-compress').click(); const rhs = page.locator('#rhsContainer'); const title = rhs.getByRole('link', {name: longTitle}); + // * Verify the title and every label stay inside the panel and nothing is clipped await expect(title).toBeVisible(); await expectInside(title, rhs); for (const label of labels) { @@ -44,16 +56,22 @@ test('a long pull request title and many labels wrap inside the right-hand panel await expectNothingClipped(rhs.locator('.scrollbar--view')); }); -test('the link tooltip stays inside the window and keeps its content', async ({pw, github}) => { +/** + * @objective Verify the pull request link tooltip stays inside the window and keeps its labels inside the tooltip. + */ +test('keeps the link tooltip inside the window with its content', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post a pull request link and hover it const pr = await seed(github); const url = `https://github.com/${repo}/pull/${pr.number}`; await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); const {page} = await openChannel(pw, github.user, github.team.name); - await page.getByRole('link', {name: url}).last().hover(); + + // * Verify the tooltip shows the title const tooltip = page.locator('.github-tooltip .box').first(); await expect(tooltip).toContainText(longTitle.slice(0, 40)); + // * Verify the tooltip is horizontally inside the viewport and its labels fit const viewport = page.viewportSize()!; const box = (await tooltip.boundingBox())!; expect(box.x).toBeGreaterThanOrEqual(0); @@ -61,13 +79,18 @@ test('the link tooltip stays inside the window and keeps its content', async ({p await expectInside(tooltip.locator('.labels'), tooltip); }); -test('a webhook post with labels fits the post', async ({github, pw}) => { +/** + * @objective Verify a pull request webhook post with a long title and labels fits inside the post. + */ +test('fits the pull request webhook post with labels inside the post', {tag: '@github_plugin'}, async ({github, pw}) => { + // # Subscribe the channel and deliver a pull_request opened webhook const pr = await seed(github); await fakeGitHub.createHook('acme', 'widgets', webhookURL, webhookSecret); await github.userClient.executeCommand(`/github subscriptions add ${repo}`, {channel_id: github.channel.id, team_id: github.team.id}); await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); const {page} = await openChannel(pw, github.user, github.team.name); + // * Verify the post's title stays inside it and nothing is clipped const post = postWithText(page, '#new-pull-request'); await expect(post).toBeVisible(); await expectInside(post.getByRole('heading', {name: longTitle}), post); @@ -75,31 +98,43 @@ test('a webhook post with labels fits the post', async ({github, pw}) => { }); test.describe('accessibility', () => { - test('the sidebar buttons and right-hand panel have no violations', async ({pw, github, axe}) => { + /** + * @objective Verify the sidebar buttons and the right-hand panel have no accessibility violations beyond the known ones. + */ + test('has no accessibility violations in the sidebar buttons and right-hand panel', {tag: '@github_plugin'}, async ({pw, github, axe}) => { + // # Open the pull requests list in the right-hand panel await seed(github); const {page} = await openChannel(pw, github.user, github.team.name); await sidebarButton(page, 'fa-compress').click(); await expect(page.locator('#rhsContainer').getByRole('link', {name: longTitle})).toBeVisible(); - await sidebarButtonBar(page).evaluate((el) => el.setAttribute('data-e2e-axe', 'github-sidebar-buttons')); + // # Run axe on the sidebar buttons and the panel + await sidebarButtonBar(page).evaluate((el) => el.setAttribute('data-e2e-axe', 'github-sidebar-buttons')); const results = await axe.builder(page).include('[data-e2e-axe="github-sidebar-buttons"]').include('#rhsContainer .scrollbar--view').analyze(); + // * Verify only the known violations are reported // Known plugin bug: the icon-only GitHub profile and refresh links have no accessible name. // Drop this entry once they are labelled; any other violation still fails. expect(results.violations.flatMap((v) => v.nodes.map((n) => `${v.id}: ${n.html.match(/fa-[\w-]+/)?.[0] ?? n.target}`))).toEqual(['link-name: fa-github', 'link-name: fa-refresh']); }); - test('the create issue modal has no violations', async ({pw, github, axe}) => { + /** + * @objective Verify the create issue modal has no accessibility violations beyond the known ones. + */ + test('has no accessibility violations in the create issue modal', {tag: '@github_plugin'}, async ({pw, github, axe}) => { + // # Open the create issue modal from a post await seed(github); await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); await openCreateIssueModal(channelsPage, page); + // # Run axe on the modal const results = await axe.builder(page).include('[role="dialog"]').analyze(); + // * Verify only the known violations are reported // Known plugin bug: the five react-selects and the description textarea have no associated label. // Drop this entry once they are labelled; any other violation still fails. const knownUnlabelled = 'label: Form elements must have labels (6)'; expect(results.violations.map((v) => `${v.id}: ${v.help} (${v.nodes.length})`)).toEqual([knownUnlabelled]); }); -}); \ No newline at end of file +}); diff --git a/e2e/integration/tests/visual.spec.ts b/e2e/integration/tests/visual.spec.ts index 46f53b81c..0cb6ff86f 100644 --- a/e2e/integration/tests/visual.spec.ts +++ b/e2e/integration/tests/visual.spec.ts @@ -11,7 +11,7 @@ import {fakeGitHub} from '../support/fake_github'; import {longTitle, repo, seedWork} from '../support/seed'; import {channelPosts, openChannel, openCreateIssueModal, postWithText, replaceText, runSlashCommand, sidebarButton, sidebarButtonBar, waitForPost} from '../support/ui'; -test.describe('@visual', () => { +test.describe('visual', {tag: '@visual'}, () => { // eslint-disable-next-line no-process-env test.skip(!process.env.PW_VISUAL_BROWSER_WS, 'baselines only match the dockerized browser (npm run test:visual)'); @@ -64,112 +64,259 @@ test.describe('@visual', () => { await fakeGitHub.webhook({repo, sender: github.login, ...request}); const post = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.includes(marker), `webhook post containing "${marker}"`); const opened = await openChannel(pw, github.user, github.team.name); - return {...opened, post, message: opened.page.locator(`#post_${post.id} .post-message__text`)}; + const message = (await opened.channelsPage.centerView.getPostById(post.id)).container.locator('.post-message__text'); + return {...opened, post, message}; } - test('sidebar buttons', async ({pw, github}) => { + /** + * @objective Verify the GitHub sidebar buttons with counts render like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for sidebar buttons', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Seed GitHub work and open the channel await seedWork(github); const {page} = await openChannel(pw, github.user, github.team.name); + + // * Verify the open pull requests count and that the sidebar buttons match the baseline await expect(sidebarButton(page, 'fa-compress')).toHaveText('1'); await shoot(pw, github, sidebarButtonBar(page), 'sidebar-buttons.png'); }); - test('open pull requests in the right-hand panel', async ({pw, github}) => { - await shoot(pw, github, await rhsTab(pw, github, 'fa-compress', longTitle), 'rhs-open-prs.png'); + /** + * @objective Verify the open pull requests list in the right-hand panel renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for open pull requests in RHS', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the open pull requests tab in the RHS + const list = await rhsTab(pw, github, 'fa-compress', longTitle); + + // * Verify the list matches the baseline + await shoot(pw, github, list, 'rhs-open-prs.png'); }); - test('review requests in the right-hand panel', async ({pw, github}) => { - await shoot(pw, github, await rhsTab(pw, github, 'fa-code-fork', 'Please review the gizmo'), 'rhs-review-requests.png'); + /** + * @objective Verify the review requests list in the right-hand panel renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for review requests in RHS', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the review requests tab in the RHS + const list = await rhsTab(pw, github, 'fa-code-fork', 'Please review the gizmo'); + + // * Verify the list matches the baseline + await shoot(pw, github, list, 'rhs-review-requests.png'); }); - test('assignments with a milestone in the right-hand panel', async ({pw, github}) => { - await shoot(pw, github, await rhsTab(pw, github, 'fa-list-ol', 'Widgets squeak on Mondays'), 'rhs-assignments.png'); + /** + * @objective Verify the assignments list with a milestone in the right-hand panel renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for assignments with milestone in RHS', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the assignments tab in the RHS + const list = await rhsTab(pw, github, 'fa-list-ol', 'Widgets squeak on Mondays'); + + // * Verify the list matches the baseline + await shoot(pw, github, list, 'rhs-assignments.png'); }); - test('unread messages in the right-hand panel', async ({pw, github}) => { - await shoot(pw, github, await rhsTab(pw, github, 'fa-envelope', 'Unread Messages'), 'rhs-unreads.png'); + /** + * @objective Verify the unread messages list in the right-hand panel renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for unread messages in RHS', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Open the unread messages tab in the RHS + const list = await rhsTab(pw, github, 'fa-envelope', 'Unread Messages'); + + // * Verify the list matches the baseline + await shoot(pw, github, list, 'rhs-unreads.png'); }); - test('pull request link tooltip', async ({pw, github}) => { + /** + * @objective Verify the tooltip shown when hovering a pull request link renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for pull request link tooltip', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post a pull request link and hover it const pr = await seedWork(github); const url = `https://github.com/${repo}/pull/${pr.number}`; await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); const {page} = await openChannel(pw, github.user, github.team.name); await page.getByRole('link', {name: url}).last().hover(); + + // * Verify the tooltip shows the pull request and matches the baseline const tooltip = page.locator('.github-tooltip .box').first(); await expect(tooltip).toContainText('Splits polishing into stages.'); await shoot(pw, github, tooltip, 'tooltip-pull.png', {keepHover: true}); }); - test('issue link tooltip', async ({pw, github}) => { + /** + * @objective Verify the tooltip shown when hovering an issue link renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for issue link tooltip', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post an issue link and hover it await seedWork(github); const url = `https://github.com/${repo}/issues/3`; await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); const {page} = await openChannel(pw, github.user, github.team.name); await page.getByRole('link', {name: url}).last().hover(); + + // * Verify the tooltip shows the issue and matches the baseline const tooltip = page.locator('.github-tooltip .box').first(); await expect(tooltip).toContainText('Widgets squeak on Mondays'); await shoot(pw, github, tooltip, 'tooltip-issue.png', {keepHover: true}); }); - test('new pull request webhook post', async ({pw, github}) => { + /** + * @objective Verify the post for a pull request opened webhook renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for new pull request webhook post', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Deliver a pull request opened webhook const pr = await seedWork(github); const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); + + // * Verify the post matches the baseline await shoot(pw, github, message, 'webhook-new-pull.png'); }); - test('merged pull request webhook post', async ({pw, github}) => { + /** + * @objective Verify the post for a merged pull request webhook renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for merged pull request webhook post', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Merge the pull request and deliver a pull request closed webhook const pr = await seedWork(github); await fakeGitHub.updateItem(repo, pr.number, {state: 'closed', merged: true}); const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'closed', number: pr.number}, 'was merged'); + + // * Verify the post matches the baseline await shoot(pw, github, message, 'webhook-merged-pull.png'); }); - test('new issue webhook post', async ({pw, github}) => { + /** + * @objective Verify the post for an issue opened webhook renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for new issue webhook post', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Deliver an issue opened webhook await seedWork(github); const {message} = await webhookPost(pw, github, {event: 'issues', action: 'opened', number: 3}, '#new-issue'); + + // * Verify the post matches the baseline await shoot(pw, github, message, 'webhook-new-issue.png'); }); - test('issue comment webhook post', async ({pw, github}) => { + /** + * @objective Verify the post for an issue comment webhook with markdown renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for issue comment webhook post', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Deliver an issue comment webhook with markdown await seedWork(github); const comment = 'Oiled them, still squeaky. Next step is replacing the **bearing**:\n\n- order part\n- swap it'; const {message} = await webhookPost(pw, github, {event: 'issue_comment', action: 'created', number: 3, comment}, 'New comment by'); + + // * Verify the post matches the baseline await shoot(pw, github, message, 'webhook-issue-comment.png'); }); - test('push webhook post', async ({pw, github}) => { + /** + * @objective Verify the post for a push webhook renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for push webhook post', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Deliver a push webhook await seedWork(github); const {message} = await webhookPost(pw, github, {event: 'push'}, 'pushed [1 new commit]'); + + // * Verify the post matches the baseline await shoot(pw, github, message, 'webhook-push.png'); }); - test('webhook post in the reply thread', async ({pw, github}) => { + /** + * @objective Verify a webhook post in the reply thread renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for webhook post in reply thread', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Deliver a pull request opened webhook and open its reply thread const pr = await seedWork(github); const {channelsPage, post} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); await (await channelsPage.centerView.getPostById(post.id)).reply(); const rhsPost = await channelsPage.sidebarRight.getPostById(post.id); + + // * Verify the thread post matches the baseline await shoot(pw, github, rhsPost.container.locator('.post-message__text'), 'reply-thread.png'); }); - test('GitHub account in the profile popover', async ({pw, github}) => { + /** + * @objective Verify the GitHub account attribute in the profile popover renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for GitHub account in profile popover', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Connect GitHub, post a message and open the author's profile popover await github.connect(); await github.userClient.createPost({channel_id: github.channel.id, message: 'Who am I on GitHub?'} as any); const {channelsPage} = await openChannel(pw, github.user, github.team.name); const popover = await channelsPage.openProfilePopover(await channelsPage.getLastPost()); const attribute = popover.container.locator('a[href^="https://github.com/"]').locator('xpath=..'); + + // * Verify the GitHub attribute matches the baseline await shoot(pw, github, attribute, 'profile-popover.png'); }); - test('create issue modal', async ({pw, github}) => { + /** + * @objective Verify the create issue modal opened from a post renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for create issue modal', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post a message and open the create issue modal from its menu await seedWork(github); await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); const modal = await openCreateIssueModal(channelsPage, page); + + // * Verify the modal matches the baseline await shoot(pw, github, modal.locator('.modal-content'), 'create-issue-modal.png'); }); - test('attach to issue modal', async ({pw, github}) => { + /** + * @objective Verify the attach to issue modal opened from a post renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for attach to issue modal', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Post a message and open the attach to issue modal from its menu await seedWork(github); await github.userClient.createPost({channel_id: github.channel.id, message: 'Attach this to an issue'} as any); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); @@ -177,15 +324,26 @@ test.describe('@visual', () => { await post.hover(); await post.postMenu.actionsButton.click(); await page.getByRole('menuitem', {name: 'Attach to GitHub Issue'}).click(); + + // * Verify the modal shows the message and matches the baseline const modal = page.getByRole('dialog').filter({hasText: 'Attach Message to GitHub Issue'}); await expect(modal.getByText('Attach this to an issue')).toBeVisible(); await shoot(pw, github, modal.locator('.modal-content'), 'attach-issue-modal.png'); }); - test('/github todo reply', async ({pw, github}) => { + /** + * @objective Verify the /github todo reply renders like the baseline. + * + * @precondition + * Runs in the pinned dockerized browser (npm run test:visual). + */ + test('matches baseline for /github todo reply', {tag: '@github_plugin'}, async ({pw, github}) => { + // # Run /github todo await seedWork(github); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); await runSlashCommand(channelsPage, '/github todo'); + + // * Verify the reply matches the baseline await shoot(pw, github, postWithText(page, 'Unread Messages').locator('.post-message__text'), 'todo.png'); }); }); diff --git a/e2e/integration/tests/webhooks.spec.ts b/e2e/integration/tests/webhooks.spec.ts index e03bef3d9..55f2ed042 100644 --- a/e2e/integration/tests/webhooks.spec.ts +++ b/e2e/integration/tests/webhooks.spec.ts @@ -25,25 +25,41 @@ function waitForChannelPost(github: GitHubFixture, text: string) { } test.describe('subscriptions', () => { - test('warns that the repository has no webhook pointing at this server', async ({pw, github}) => { + /** + * @objective Verify subscribing to a repository with no webhook pointing at this server posts a setup hint. + * + * @precondition + * The user connects with the `repo` scope so the plugin can list repository hooks. + */ + test('warns on subscribe when the repository has no webhook pointing at this server', {tag: '@github_plugin'}, async ({pw, github}) => { await fakeGitHub.createRepo(`${github.login}/widgets`); await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + // # Subscribe the channel to the repository await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + // * Verify the subscription is confirmed with a webhook setup hint await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); await expect(postWithText(page, noWebhookNote)).toContainText('/github setup webhook'); }); - test('drops the warning once a webhook points at this server', async ({pw, github}) => { + /** + * @objective Verify subscribing to a repository whose webhook already points at this server posts no warning. + * + * @precondition + * The user connects with the `repo` scope so the plugin can list repository hooks. + */ + test('omits the no-webhook warning on subscribe when a webhook points at this server', {tag: '@github_plugin'}, async ({pw, github}) => { await fakeGitHub.createRepo(`${github.login}/widgets`); await fakeGitHub.createHook(github.login, 'widgets', webhookURL, webhookSecret); await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + // # Subscribe the channel to the repository await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + // * Verify the subscription is confirmed after the hooks are checked, with no warning await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); await expect.poll(() => fakeGitHub.calls()).toContain(`GET /api/repos/${github.login}/widgets/hooks`); await expect(postWithText(page, noWebhookNote)).toHaveCount(0); @@ -51,13 +67,18 @@ test.describe('subscriptions', () => { // Listing repository hooks needs `repo` or `read:repo_hook`; the plugin's public-repo scopes have neither, // GitHub answers 404, and the plugin treats that as "can't tell" instead of warning. - test('cannot check for a webhook with the default public-repo scopes', async ({pw, github}) => { + /** + * @objective Verify the plugin skips the no-webhook warning when the default scopes cannot list repository hooks. + */ + test('skips the no-webhook warning on subscribe with the default public-repo scopes', {tag: '@github_plugin'}, async ({pw, github}) => { await fakeGitHub.createRepo(`${github.login}/widgets`); await github.connect(); const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + // # Subscribe the channel to the repository await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + // * Verify the subscription is confirmed, the hooks check got 404 and no warning is posted await expect(postWithText(page, 'subscribed this channel to')).toBeVisible(); await expect.poll(async () => (await fakeGitHub.requests()).find((r) => r.path === `/api/repos/${github.login}/widgets/hooks`)?.status).toBe(404); await expect(postWithText(page, noWebhookNote)).toHaveCount(0); @@ -65,57 +86,85 @@ test.describe('subscriptions', () => { }); test.describe('webhook events', () => { - test('a new pull request is posted to the subscribed channel', async ({github}) => { + /** + * @objective Verify a pull_request opened webhook posts the new pull request to the subscribed channel. + */ + test('posts a new pull request to the subscribed channel', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { const repo = await subscribedRepo(github); const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: github.login, body: 'Polishes widgets.', labels: ['enhancement']}); + // # Deliver a pull_request opened webhook const {deliveries} = await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); + // * Verify the delivery is accepted and the channel gets the pull request post expect(deliveries.map((d) => d.status)).toEqual([200]); const post = await waitForChannelPost(github, 'Add widget polishing'); expect(post.message).toContain('#new-pull-request'); expect(post.message).toContain(`https://github.com/${repo}/pull/${pr.number}`); }); - test('a new issue is posted to the subscribed channel', async ({github}) => { + /** + * @objective Verify an issues opened webhook posts the new issue to the subscribed channel. + */ + test('posts a new issue to the subscribed channel', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { const repo = await subscribedRepo(github); const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets wobble', author: github.login, body: 'They wobble.'}); + // # Deliver an issues opened webhook await fakeGitHub.webhook({event: 'issues', action: 'opened', repo, number: issue.number, sender: github.login}); + // * Verify the channel gets the new issue post const post = await waitForChannelPost(github, 'Widgets wobble'); expect(post.message).toContain('#new-issue'); }); - test('an issue comment is posted to the subscribed channel', async ({github}) => { + /** + * @objective Verify an issue_comment created webhook posts the comment to the subscribed channel. + */ + test('posts an issue comment to the subscribed channel', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { const repo = await subscribedRepo(github); const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak', author: github.login}); + // # Deliver an issue_comment created webhook await fakeGitHub.webhook({event: 'issue_comment', action: 'created', repo, number: issue.number, sender: github.login, comment: 'Oiled them, still squeaky.'}); + // * Verify the channel gets the comment post const post = await waitForChannelPost(github, 'Oiled them, still squeaky.'); expect(post.message).toContain('New comment by'); }); - test('a push is posted to the subscribed channel', async ({github}) => { + /** + * @objective Verify a push webhook posts the pushed commits to the subscribed channel. + */ + test('posts a push to the subscribed channel', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { const repo = await subscribedRepo(github); + // # Deliver a push webhook await fakeGitHub.webhook({event: 'push', repo, sender: github.login}); + // * Verify the channel gets the push post const post = await waitForChannelPost(github, 'Update README'); expect(post.message).toContain('pushed [1 new commit]'); expect(post.message).toContain(`${repo}:main`); }); - test('a review request DMs the connected reviewer', async ({pw, github}) => { + /** + * @objective Verify a review_requested webhook DMs the requested reviewer when they are connected. + * + * @precondition + * A second user is created and connected as the reviewer. + */ + test('DMs the connected reviewer when a pull request review is requested', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({pw, github}) => { const repo = await subscribedRepo(github); const reviewer = await pw.createNewUserProfile(github.adminClient, {prefix: 'reviewer', disableTutorial: true, disableOnboarding: true}); await github.adminClient.addToTeam(github.team.id, reviewer.id); const {login: reviewerLogin} = await github.connect(reviewer); const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Review my widget', author: github.login, reviewers: [reviewerLogin]}); + // # Deliver a pull_request review_requested webhook await fakeGitHub.webhook({event: 'pull_request', action: 'review_requested', repo, number: pr.number, sender: github.login, requestedReviewer: reviewerLogin}); + // * Verify the reviewer gets a review request DM naming the pull request const dm = await waitForPost(() => botDMPosts(github.adminClient, reviewer.id), (p) => p.message.includes('requested your review'), 'review request DM'); expect(dm.message).toContain('Review my widget'); }); @@ -132,15 +181,36 @@ test.describe('webhook signatures', () => { return {status: res.status, posted: posts.some((p) => p.message.includes('Tampered issue'))}; } - test('a delivery without X-Hub-Signature is rejected', async ({github}) => { - expect(await deliverRaw(github, () => ({}))).toEqual({status: 401, posted: false}); + /** + * @objective Verify the plugin rejects an unsigned webhook delivery and posts nothing. + */ + test('rejects webhook delivery without X-Hub-Signature', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { + // # Deliver an unsigned issues webhook + const result = await deliverRaw(github, () => ({})); + + // * Verify it is rejected with 401 and nothing is posted + expect(result).toEqual({status: 401, posted: false}); }); - test('a delivery signed with another secret is rejected', async ({github}) => { - expect(await deliverRaw(github, (body) => signatures('not-the-webhook-secret', body))).toEqual({status: 401, posted: false}); + /** + * @objective Verify the plugin rejects a webhook delivery signed with the wrong secret and posts nothing. + */ + test('rejects webhook delivery signed with another secret', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { + // # Deliver an issues webhook signed with the wrong secret + const result = await deliverRaw(github, (body) => signatures('not-the-webhook-secret', body)); + + // * Verify it is rejected with 401 and nothing is posted + expect(result).toEqual({status: 401, posted: false}); }); - test('a delivery signed with the right secret is accepted', async ({github}) => { - expect(await deliverRaw(github, (body) => signatures(webhookSecret, body))).toEqual({status: 200, posted: true}); + /** + * @objective Verify the plugin accepts a webhook delivery signed with the configured secret and posts it. + */ + test('accepts webhook delivery signed with the right secret', {tag: ['@github_plugin', '@synthetic-inbound']}, async ({github}) => { + // # Deliver an issues webhook signed with the configured secret + const result = await deliverRaw(github, (body) => signatures(webhookSecret, body)); + + // * Verify it is accepted and the issue is posted + expect(result).toEqual({status: 200, posted: true}); }); }); diff --git a/server/plugin/graphql/client_test.go b/server/plugin/graphql/client_test.go new file mode 100644 index 000000000..5b0bfcbb6 --- /dev/null +++ b/server/plugin/graphql/client_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package graphql + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost/server/public/pluginapi" +) + +func TestNewClientWithURLQueriesTheGivenEndpoint(t *testing.T) { + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"octocat"}}}`)) + })) + defer server.Close() + + client := NewClientWithURL(pluginapi.LogService{}, nil, oauth2.Token{AccessToken: "token"}, "octocat", "", server.URL+"/api/graphql") + + var query struct { + Viewer struct { + Login githubv4.String + } + } + require.NoError(t, client.executeQuery(context.Background(), &query, nil)) + + assert.Equal(t, "/api/graphql", gotPath) + assert.Equal(t, "Bearer token", gotAuth) + assert.Equal(t, githubv4.String("octocat"), query.Viewer.Login) +} + +func TestNewClientUsesTheEnterpriseGraphQLPath(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"octocat"}}}`)) + })) + defer server.Close() + + client := NewClient(pluginapi.LogService{}, nil, oauth2.Token{AccessToken: "token"}, "octocat", "", server.URL) + + var query struct { + Viewer struct { + Login githubv4.String + } + } + require.NoError(t, client.executeQuery(context.Background(), &query, nil)) + + assert.Equal(t, "/api/graphql", gotPath) +} diff --git a/webapp/src/components/github_repo_selector/github_repo_selector.test.jsx b/webapp/src/components/github_repo_selector/github_repo_selector.test.jsx new file mode 100644 index 000000000..7c103c0f3 --- /dev/null +++ b/webapp/src/components/github_repo_selector/github_repo_selector.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import {act, render} from '@testing-library/react'; + +import GithubRepoSelector from './github_repo_selector'; + +const selectProps = {}; + +jest.mock('@/components/react_select_setting', () => ({ + __esModule: true, + default: ({name, onChange}) => { + selectProps[name] = {onChange}; + return

; + }, +})); + +const baseProps = { + yourOrgs: [{login: 'acme'}, {login: 'globex'}], + yourReposByOrg: {repos: []}, + theme: {}, + onChange: jest.fn(), + value: 'acme/widgets', + currentChannelId: 'channel-id', + actions: { + getOrgs: jest.fn(), + getReposByOrg: jest.fn(), + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('picking an organization loads that organization\'s repositories', () => { + render(); + + act(() => selectProps.org.onChange('org', 'globex')); + + expect(baseProps.actions.getReposByOrg).toHaveBeenCalledWith('globex', 'channel-id'); + expect(baseProps.onChange).toHaveBeenCalledWith(null); +}); + +test('a new organization list selects its first organization', () => { + const {rerender} = render(); + + rerender( + , + ); + + expect(baseProps.actions.getReposByOrg).toHaveBeenCalledWith('initech', 'channel-id'); +});