diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..54c611613 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,211 @@ +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 + 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 + 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 + 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 + 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/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..b5366d608 --- /dev/null +++ b/e2e/integration/README.md @@ -0,0 +1,84 @@ +# 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. + +## 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 + +- `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. + +## 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/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..e21e3961b --- /dev/null +++ b/e2e/integration/fake-github/contract/validator.ts @@ -0,0 +1,221 @@ +// 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; 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(), + 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))]] : []; + })), + 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)))])); +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.documented.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..f06642a09 --- /dev/null +++ b/e2e/integration/real-github/api.ts @@ -0,0 +1,68 @@ +// 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; + } + } + 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; + } +} + +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..87e52931f --- /dev/null +++ b/e2e/integration/real-github/journey.spec.ts @@ -0,0 +1,213 @@ +// 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', () => { + /** + * @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([]); + } + }); + + /** + * @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); + }); + + /** + * @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(); + + // * 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'); + 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'); + + /** + * @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)); + + // * 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 { + // 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..c89c11e84 --- /dev/null +++ b/e2e/integration/scripts/visual.sh @@ -0,0 +1,19 @@ +#!/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_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 + +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 000000000..de6ecf46c Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/create-issue-modal.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/create-issue-modal.png new file mode 100644 index 000000000..295cd6bd7 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/create-issue-modal.png differ 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 000000000..c78e069ed Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/profile-popover.png differ 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 000000000..18e5fe123 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/reply-thread.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-assignments.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-assignments.png new file mode 100644 index 000000000..d83ffdb97 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-assignments.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-open-prs.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-open-prs.png new file mode 100644 index 000000000..cdff6ee2b Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-open-prs.png differ 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 000000000..ecaa04890 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-review-requests.png differ 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 000000000..1428d95ad Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/rhs-unreads.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/sidebar-buttons.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/sidebar-buttons.png new file mode 100644 index 000000000..10561fd9d Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/sidebar-buttons.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/todo.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/todo.png new file mode 100644 index 000000000..0d43d4530 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/todo.png differ 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 000000000..c1ff775c0 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-issue.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-pull.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-pull.png new file mode 100644 index 000000000..8ab09ad88 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/tooltip-pull.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-issue-comment.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-issue-comment.png new file mode 100644 index 000000000..ba26f2be1 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-issue-comment.png differ 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 000000000..0f1a6c8cd Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-merged-pull.png differ diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-issue.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-issue.png new file mode 100644 index 000000000..3c9057238 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-issue.png differ 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 000000000..e7bb9eb25 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-new-pull.png differ 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 000000000..af0192386 Binary files /dev/null and b/e2e/integration/tests/__screenshots__/visual.spec.ts/webhook-push.png differ diff --git a/e2e/integration/tests/connect.spec.ts b/e2e/integration/tests/connect.spec.ts new file mode 100644 index 000000000..de7b8ed0e --- /dev/null +++ b/e2e/integration/tests/connect.spec.ts @@ -0,0 +1,178 @@ +// 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', () => { + /** + * @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(); + + // * 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'])); + }); + + /** + * @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. + /** + * @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'); + }); + + /** + * @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. + /** + * @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); + }); + + /** + * @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); + }); +}); + +test.describe('auth failures', () => { + /** + * @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); + }); + + /** + * @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); + 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..5577c6455 --- /dev/null +++ b/e2e/integration/tests/contract.spec.ts @@ -0,0 +1,308 @@ +// 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([]); + }); + + /** + * @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); + }); + + /** + * @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); + }); + + /** + * @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']); + }); + + /** + * @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'); + 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); + }); + + /** + * @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'}, + {__typename: 'Team', slug: 'core', organization: {login: 'acme'}}, + ]); + }); + + /** + * @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'); + }); + + /** + * @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'}); + }); + + /** + * @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'}, + {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([]); + } + + // * 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}$/), + }); + }); + + /** + * @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) => { + received.push(String(req.headers['x-github-event'])); + res.end(); + }); + 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 { + sink.close(); + } + }); +}); + +test.describe('fake semantics', () => { + /** + * @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}, + {qualifier: 'org', values: ['a', 'b'], negated: false}, + {qualifier: 'author', values: ['alice'], negated: false}, + {qualifier: 'text', values: ['two words'], negated: false}, + ]); + }); + + /** + * @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']); + }); + + /** + * @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([]); + }); + + /** + * @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 new file mode 100644 index 000000000..0e62be01a --- /dev/null +++ b/e2e/integration/tests/issues.spec.ts @@ -0,0 +1,138 @@ +// 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', () => { + /** + * @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}`); + 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}`); + }); + + /** + * @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.'); + }); +}); + +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)}; + } + + /** + * @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(); + + // * 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. + /** + * @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: []}); + }); + + /** + * @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 new file mode 100644 index 000000000..20898e045 --- /dev/null +++ b/e2e/integration/tests/sidebar.spec.ts @@ -0,0 +1,105 @@ +// 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'}); +} + +/** + * @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(); + await expect(rhs.getByText('Closed widget work')).toHaveCount(0); + 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`])); +}); + +/** + * @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'); + } 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..4cd426062 --- /dev/null +++ b/e2e/integration/tests/smoke.spec.ts @@ -0,0 +1,21 @@ +// 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'; + +/** + * @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 new file mode 100644 index 000000000..08318d74f --- /dev/null +++ b/e2e/integration/tests/todo.spec.ts @@ -0,0 +1,41 @@ +// 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'; + +/** + * @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']}); + 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'); + + // * 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'); + 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'); + + // * 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}`), + 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..a61ab880d --- /dev/null +++ b/e2e/integration/tests/ui_layout.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 {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)); +} + +/** + * @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 {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'); + + // * Verify each button sits inside the sidebar and none overlap + const sidebar = channelsPage.sidebarLeft.container; + for (const button of buttons) { + await expectInside(button, sidebar); + } + for (let i = 1; i < buttons.length; i++) { + await expectNoOverlap(buttons[i - 1], buttons[i]); + } +}); + +/** + * @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) { + await expectInside(rhs.getByLabel(label), rhs); + } + await expectNothingClipped(rhs.locator('.scrollbar--view')); +}); + +/** + * @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); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + await expectInside(tooltip.locator('.labels'), tooltip); +}); + +/** + * @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); + await expectNothingClipped(post.locator('.post-message__text')); +}); + +test.describe('accessibility', () => { + /** + * @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(); + + // # 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']); + }); + + /** + * @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]); + }); +}); diff --git a/e2e/integration/tests/visual.spec.ts b/e2e/integration/tests/visual.spec.ts new file mode 100644 index 000000000..0cb6ff86f --- /dev/null +++ b/e2e/integration/tests/visual.spec.ts @@ -0,0 +1,349 @@ +// 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', {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)'); + + // 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); + const message = (await opened.channelsPage.centerView.getPostById(post.id)).container.locator('.post-message__text'); + return {...opened, post, message}; + } + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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}); + }); + + /** + * @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}); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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); + const post = await channelsPage.getLastPost(); + 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'); + }); + + /** + * @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 new file mode 100644 index 000000000..55f2ed042 --- /dev/null +++ b/e2e/integration/tests/webhooks.spec.ts @@ -0,0 +1,216 @@ +// 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', () => { + /** + * @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'); + }); + + /** + * @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); + }); + + // 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. + /** + * @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); + }); +}); + +test.describe('webhook events', () => { + /** + * @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}`); + }); + + /** + * @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'); + }); + + /** + * @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'); + }); + + /** + * @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`); + }); + + /** + * @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'); + }); +}); + +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'))}; + } + + /** + * @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}); + }); + + /** + * @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}); + }); + + /** + * @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/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/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/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); 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'); +});