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..4f340569e --- /dev/null +++ b/e2e/integration/README.md @@ -0,0 +1,59 @@ +# GitHub plugin Playwright tests + +Two lanes. The fake lane runs on every PR, including forks, with no secrets. The real lane talks to github.com and runs nightly only when enabled. + +The older suite in `e2e/playwright` (and `playwright.yml`) is unchanged and still runs against its own mock. + +```sh +make e2e-env-up # Postgres + Mattermost on :8069 +make e2e # builds the E2E bundle (make e2e-dist), installs the plugin, runs the suite +make e2e-env-down + +make e2e-real # normal bundle, against github.com. Needs e2e/integration/.env +``` + +`make e2e-dist` bakes `http://fake-github:8093` into `testOAuthServerURL`, which now moves the OAuth, REST, GraphQL and upload base URLs together, so the github.com code path is what gets tested. The plugin logs a warning on activation. Never ship that bundle. + +Overrides go in the shell or in `e2e/integration/.env` (copy `.env.example`; docker compose reads it too). Real values live in 1Password or GitHub Actions secrets. + +| Variable | Default | Purpose | +|---|---|---| +| `MM_IMAGE` | `mattermostdevelopment/mattermost-enterprise-edition:master` | Server image | +| `MM_PORT` | `8069` | Host port; must match between `e2e-env-up` and `e2e` | +| `PW_BROWSER_PORT` | `3336` | Host port of the dockerized visual browser | +| `PW_HEADLESS` | `true` | `false` to watch the browser | + +## How GitHub is faked + +- `fake-github/server.ts` runs inside the Playwright process on port 8093. Mattermost reaches it as `fake-github` through `extra_hosts`; Chromium through `--host-resolver-rules`. +- It is a classic OAuth app: tokens never expire and have no refresh token. Token errors come back as HTTP 200 with an `error` field, as form or JSON depending on `Accept`. Scopes from earlier grants are merged, and consent is skipped when an earlier grant already covers the request. +- Permissions follow GitHub: private repos need `repo`; repo hooks need repo admin plus `repo` or a `*:repo_hook` scope, otherwise 404; org hooks need org admin plus `admin:org_hook`; notifications need `notifications` or `repo`; issue labels, assignees and milestone are silently dropped without push access; SAML-enforced orgs answer 403 with `X-GitHub-SSO`. +- Creating an issue fires `issues.opened` to matching hooks and creating a hook sends `ping`, signed with `X-Hub-Signature` (HMAC-SHA1) and `X-Hub-Signature-256`. +- Tests drive it through `support/fake_github.ts` (`reset`, `createRepo`, `createItem`, `webhook`, `fault`, `sso`, `revoke`, `requests`, ...). +- Only `connect.spec.ts` drives the browser OAuth flow. Other specs connect with `seedUserInfo()` (`support/kv.ts`), which writes `_githubtoken` with the plugin's AES-CFB cipher. `installPlugin()` also seeds `mm34646_token_reset_done`, or the plugin resets every stored token a minute after it starts. +- Don't use `pw.initSetup()`: it resets server config and disables the plugin. + +## Keeping the fake honest + +Every request and response is checked, and each test fails at teardown if anything broke the contract. + +- **REST:** validated against [github/rest-api-description](https://github.com/github/rest-api-description) (`api.github.com.json`), trimmed to the operations in `fake-github/contract/operations.json`. A route that isn't listed fails the run. +- **GraphQL:** queries are validated and executed against GitHub's [public schema](https://docs.github.com/en/graphql/overview/public-schema), trimmed to the types the plugin uses. graphql-js rejects `... on Issue { state } ... on PullRequest { state }` as a field conflict while GitHub answers it, so that one message is ignored. The real lane checks GitHub still accepts it. +- **Webhooks:** validated against the `x-webhooks` schemas in the same REST description. These replaced [octokit/webhooks](https://github.com/octokit/webhooks), which is deprecated and no longer updated. +- Nightly, `npm run contract:check` fails if GitHub changed the committed specs. `npm run contract:update` refreshes them. + +## Real GitHub + +`npm run test:real` (or `make e2e-real`) needs the variables in `.env.example`. Global setup: + +- Creates long-lived fixtures once and reuses them: an `e2e-fixture` label, an open issue assigned to the machine account, and an open pull request from the `e2e-fixture` branch. It then polls search until both show up, because the plugin finds them through search and the index lags. +- Points a repository hook at a public placeholder URL with a fresh secret. GitHub refuses hooks to private addresses, so tests fetch the recorded delivery from the hook deliveries API and post it to the plugin. +- Seeds the machine account's token into KV, so most tests skip the browser login. + +The sign-in test logs in with password and TOTP, then revokes its grant so the next run starts clean. GitHub skips its emailed device check for accounts with 2FA. + +Writes go through `real-github/rate_limit.ts`, which stays under GitHub's content limits (80 a minute, 500 an hour) across runs on one machine. Retries are off so failures don't repeat writes. Traces, screenshots and video are off when `CI` is set. Don't put account, organization or repository names in this tree. + +## Screenshots + +`npm run test:visual` compares `@visual` shots in a dockerized browser against a pinned server image, in its own throwaway stack, and does not fail the PR job. Update baselines with `npm run test:visual-update-snapshots` and commit the PNGs. diff --git a/e2e/integration/docker-compose.yml b/e2e/integration/docker-compose.yml new file mode 100644 index 000000000..1bd3d7745 --- /dev/null +++ b/e2e/integration/docker-compose.yml @@ -0,0 +1,62 @@ +name: github-plugin-e2e + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: mmuser + POSTGRES_PASSWORD: mostest + POSTGRES_DB: mattermost_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mmuser -d mattermost_test"] + interval: 5s + timeout: 5s + retries: 10 + tmpfs: + - /var/lib/postgresql/data + + mattermost: + image: ${MM_IMAGE:-mattermostdevelopment/mattermost-enterprise-edition:master} + # Server images are published for amd64 only; Apple Silicon runs them under emulation. + platform: linux/amd64 + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:${MM_PORT:-8069}:${MM_PORT:-8069}" + - "127.0.0.1:${PW_BROWSER_PORT:-3336}:3333" + extra_hosts: + - "fake-github:host-gateway" + environment: + MM_SQLSETTINGS_DRIVERNAME: postgres + MM_SQLSETTINGS_DATASOURCE: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10 + MM_SERVICESETTINGS_SITEURL: http://127.0.0.1:${MM_PORT:-8069} + MM_SERVICESETTINGS_LISTENADDRESS: ":${MM_PORT:-8069}" + MM_SERVICESETTINGS_ENABLELOCALMODE: "true" + MM_PLUGINSETTINGS_ENABLE: "true" + MM_PLUGINSETTINGS_ENABLEUPLOADS: "true" + MM_PLUGINSETTINGS_AUTOMATICPREPACKAGEDPLUGINS: "false" + MM_TEAMSETTINGS_ENABLEOPENSERVER: "true" + MM_ANNOUNCEMENTSETTINGS_ADMINNOTICESENABLED: "false" + MM_ANNOUNCEMENTSETTINGS_USERNOTICESENABLED: "false" + MM_LOGSETTINGS_CONSOLELEVEL: INFO + healthcheck: + test: ["CMD", "/mattermost/bin/mmctl", "--local", "system", "status"] + interval: 5s + timeout: 5s + retries: 60 + + # Shares Mattermost's network so the page origin matches the URL the tests use from the host. + visual-browser: + profiles: [visual] + image: mcr.microsoft.com/playwright:v${PW_VERSION:-1.64.1}-noble + network_mode: service:mattermost + # Chromium rasterizes text differently per CPU architecture; CI runners are amd64. + platform: linux/amd64 + init: true + command: npx -y playwright@${PW_VERSION:-1.64.1} run-server --port 3333 --host 0.0.0.0 + healthcheck: + test: ["CMD", "node", "-e", "require('net').connect(3333, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 60 diff --git a/e2e/integration/fake-github/contract/operations.json b/e2e/integration/fake-github/contract/operations.json new file mode 100644 index 000000000..45cf4f222 --- /dev/null +++ b/e2e/integration/fake-github/contract/operations.json @@ -0,0 +1,62 @@ +{ + "rest": { + "url": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", + "operations": [ + "GET /user", + "GET /users/{username}", + "GET /user/orgs", + "GET /user/repos", + "GET /users/{username}/repos", + "GET /orgs/{org}", + "GET /orgs/{org}/repos", + "GET /orgs/{org}/members/{username}", + "GET /orgs/{org}/public_members/{username}", + "GET /orgs/{org}/hooks", + "POST /orgs/{org}/hooks", + "GET /repos/{owner}/{repo}", + "GET /repos/{owner}/{repo}/hooks", + "POST /repos/{owner}/{repo}/hooks", + "GET /repos/{owner}/{repo}/issues/{issue_number}", + "POST /repos/{owner}/{repo}/issues", + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + "GET /repos/{owner}/{repo}/labels", + "GET /repos/{owner}/{repo}/assignees", + "GET /repos/{owner}/{repo}/milestones", + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews", + "GET /repos/{owner}/{repo}/commits/{ref}/status", + "GET /search/issues", + "GET /notifications" + ], + "webhooks": [ + "ping", + "pull-request-opened", + "pull-request-closed", + "pull-request-review-requested", + "issues-opened", + "issue-comment-created", + "push" + ] + }, + "graphql": { + "url": "https://docs.github.com/public/fpt/schema.docs.graphql", + "fields": { + "Query": ["search"], + "SearchResultItemConnection": ["issueCount", "nodes", "pageInfo"], + "PageInfo": ["endCursor", "hasNextPage"], + "PullRequest": ["additions", "author", "authorAssociation", "body", "changedFiles", "createdAt", "deletions", "labels", "milestone", "number", "repository", "reviewRequests", "state", "title", "updatedAt", "url"], + "Issue": ["author", "authorAssociation", "body", "createdAt", "labels", "milestone", "number", "repository", "state", "title", "updatedAt", "url"], + "Actor": ["login"], + "User": ["login"], + "Team": ["slug", "organization"], + "Organization": ["login"], + "Repository": ["name", "nameWithOwner", "owner", "url"], + "RepositoryOwner": ["login"], + "LabelConnection": ["nodes"], + "Label": ["color", "name"], + "Milestone": ["title"], + "ReviewRequestConnection": ["nodes"], + "ReviewRequest": ["requestedReviewer"] + } + } +} diff --git a/e2e/integration/fake-github/contract/specs/rest.json b/e2e/integration/fake-github/contract/specs/rest.json new file mode 100644 index 000000000..7c877c9e9 --- /dev/null +++ b/e2e/integration/fake-github/contract/specs/rest.json @@ -0,0 +1,19416 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "GitHub v3 REST API" + }, + "paths": { + "/user": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/private-user" + }, + { + "$ref": "#/components/schemas/public-user" + } + ], + "discriminator": { + "propertyName": "user_view_type", + "mapping": { + "public": "#/components/schemas/public-user", + "private": "#/components/schemas/private-user" + } + } + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + } + } + } + }, + "/users/{username}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/private-user" + }, + { + "$ref": "#/components/schemas/public-user" + } + ], + "discriminator": { + "propertyName": "user_view_type", + "mapping": { + "public": "#/components/schemas/public-user", + "private": "#/components/schemas/private-user" + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/user/orgs": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/organization-simple" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + } + } + } + }, + "/user/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/users/{username}/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/minimal-repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/orgs/{org}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/organization-full" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/orgs/{org}/repos": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/minimal-repository" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/orgs/{org}/members/{username}": { + "get": { + "responses": { + "204": {}, + "302": { + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": {} + } + } + }, + "/orgs/{org}/public_members/{username}": { + "get": { + "responses": { + "204": {}, + "404": {} + } + } + }, + "/orgs/{org}/hooks": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/org-hook" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + }, + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "events": { + "type": "array", + "default": [ + "push" + ], + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name", + "config" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/org-hook" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/full-repository" + } + } + } + }, + "301": { + "$ref": "#/components/responses/moved_permanently" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/hooks": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/hook" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + }, + "post": { + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + } + } + }, + "events": { + "type": "array", + "default": [ + "push" + ], + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/hook" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{issue_number}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue" + } + } + } + }, + "301": { + "$ref": "#/components/responses/moved_permanently" + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + } + } + } + }, + "/repos/{owner}/{repo}/issues": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "body": { + "type": "string" + }, + "assignee": { + "type": "string", + "nullable": true + }, + "milestone": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "nullable": true + }, + "labels": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "nullable": true + } + } + } + ] + } + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + }, + "issue_field_values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "integer" + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "field_id", + "value" + ], + "additionalProperties": false + } + }, + "type": { + "type": "string", + "nullable": true + }, + "parent_issue_id": { + "type": "integer" + } + }, + "required": [ + "title" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad_request" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{issue_number}/comments": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "string" + } + }, + "required": [ + "body" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/issue-comment" + } + } + }, + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "410": { + "$ref": "#/components/responses/gone" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + }, + "/repos/{owner}/{repo}/labels": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/label" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/assignees": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/milestones": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/milestone" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/repos/{owner}/{repo}/pulls/{pull_number}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pull-request" + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "404": { + "$ref": "#/components/responses/not_found" + }, + "406": { + "$ref": "#/components/responses/unacceptable" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "500": { + "$ref": "#/components/responses/internal_error" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/repos/{owner}/{repo}/pulls/{pull_number}/reviews": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/pull-request-review" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + } + } + } + }, + "/repos/{owner}/{repo}/commits/{ref}/status": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/combined-commit-status" + } + } + } + }, + "404": { + "$ref": "#/components/responses/not_found" + } + } + } + }, + "/search/issues": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "total_count", + "incomplete_results", + "items", + "search_type" + ], + "properties": { + "total_count": { + "type": "integer" + }, + "incomplete_results": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-search-result-item" + } + }, + "search_type": { + "type": "string", + "enum": [ + "lexical", + "semantic", + "hybrid" + ] + }, + "lexical_fallback_reason": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "no_text_terms", + "quoted_text", + "non_issue_target", + "or_boolean_not_supported", + "no_accessible_repos", + "server_error", + "only_non_semantic_fields_requested", + "service_unavailable" + ] + } + } + } + } + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + }, + "503": { + "$ref": "#/components/responses/service_unavailable" + } + } + } + }, + "/notifications": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/thread" + } + } + } + }, + "headers": { + "Link": { + "$ref": "#/components/headers/link" + } + } + }, + "304": { + "$ref": "#/components/responses/not_modified" + }, + "401": { + "$ref": "#/components/responses/requires_authentication" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "422": { + "$ref": "#/components/responses/validation_failed" + } + } + } + } + }, + "x-webhooks": { + "ping": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-ping" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/webhook-ping-form-encoded" + } + } + } + } + } + }, + "pull-request-opened": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-opened" + } + } + } + } + } + }, + "pull-request-closed": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-closed" + } + } + } + } + } + }, + "pull-request-review-requested": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-pull-request-review-requested" + } + } + } + } + } + }, + "issues-opened": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-issues-opened" + } + } + } + } + } + }, + "issue-comment-created": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-issue-comment-created" + } + } + } + } + } + }, + "push": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhook-push" + } + } + } + } + } + } + }, + "components": { + "headers": { + "link": { + "schema": { + "type": "string" + } + } + }, + "responses": { + "bad_request": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + }, + "application/scim+json": { + "schema": { + "$ref": "#/components/schemas/scim-error" + } + } + } + }, + "forbidden": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "gone": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "internal_error": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "moved_permanently": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "not_found": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "not_modified": {}, + "requires_authentication": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "service_unavailable": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + } + } + } + } + } + }, + "unacceptable": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/basic-error" + } + } + } + }, + "validation_failed": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/validation-error" + } + } + } + } + }, + "schemas": { + "author-association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto-merge": { + "type": "object", + "properties": { + "enabled_by": { + "$ref": "#/components/schemas/simple-user" + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + "commit_title": { + "type": "string" + }, + "commit_message": { + "type": "string" + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ], + "nullable": true + }, + "basic-error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "code-of-conduct": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "url", + "html_url", + "key", + "name" + ] + }, + "code-of-conduct-simple": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "html_url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "url", + "key", + "name", + "html_url" + ] + }, + "combined-commit-status": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-commit-status" + } + }, + "sha": { + "type": "string" + }, + "total_count": { + "type": "integer" + }, + "repository": { + "$ref": "#/components/schemas/minimal-repository" + }, + "commit_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "state", + "sha", + "total_count", + "statuses", + "repository", + "commit_url", + "url" + ] + }, + "enterprise": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "website_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "avatar_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "name", + "slug", + "html_url", + "created_at", + "updated_at", + "avatar_url" + ] + }, + "enterprise-webhooks": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "website_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "avatar_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "name", + "slug", + "html_url", + "created_at", + "updated_at", + "avatar_url" + ] + }, + "full-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "template_repository": { + "$ref": "#/components/schemas/nullable-repository" + }, + "temp_clone_token": { + "type": "string", + "nullable": true + }, + "allow_squash_merge": { + "type": "boolean" + }, + "allow_auto_merge": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean" + }, + "allow_update_branch": { + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean" + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "organization": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "parent": { + "$ref": "#/components/schemas/repository" + }, + "source": { + "$ref": "#/components/schemas/repository" + }, + "forks": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "anonymous_access_enabled": { + "default": true, + "type": "boolean" + }, + "code_of_conduct": { + "$ref": "#/components/schemas/code-of-conduct-simple" + }, + "security_and_analysis": { + "$ref": "#/components/schemas/security-and-analysis" + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "has_discussions", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at", + "network_count", + "subscribers_count" + ] + }, + "hook": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "config": { + "$ref": "#/components/schemas/webhook-config" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "test_url": { + "type": "string", + "format": "uri" + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "last_response": { + "$ref": "#/components/schemas/hook-response" + } + }, + "required": [ + "id", + "url", + "type", + "name", + "active", + "events", + "config", + "ping_url", + "created_at", + "updated_at", + "last_response", + "test_url" + ] + }, + "hook-response": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + } + }, + "required": [ + "code", + "status", + "message" + ] + }, + "issue": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string", + "nullable": true, + "enum": [ + "completed", + "reopened", + "not_planned", + "duplicate" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "labels": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "nullable": true + }, + "default": { + "type": "boolean" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + } + } + ] + } + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "locked": { + "type": "boolean" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "comments": { + "type": "integer" + }, + "pull_request": { + "type": "object", + "properties": { + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "diff_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "patch_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "diff_url", + "html_url", + "patch_url", + "url" + ] + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "closed_by": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "repository": { + "$ref": "#/components/schemas/repository" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "parent_issue_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + } + }, + "required": [ + "assignee", + "closed_at", + "comments", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "labels", + "labels_url", + "milestone", + "number", + "repository_url", + "state", + "locked", + "title", + "url", + "user", + "created_at", + "updated_at" + ] + }, + "issue-comment": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + } + }, + "required": [ + "id", + "node_id", + "html_url", + "issue_url", + "user", + "url", + "created_at", + "updated_at" + ] + }, + "issue-dependencies-summary": { + "type": "object", + "properties": { + "blocked_by": { + "type": "integer" + }, + "blocking": { + "type": "integer" + }, + "total_blocked_by": { + "type": "integer" + }, + "total_blocking": { + "type": "integer" + } + }, + "required": [ + "blocked_by", + "blocking", + "total_blocked_by", + "total_blocking" + ] + }, + "issue-field-value": { + "type": "object", + "properties": { + "issue_field_id": { + "type": "integer", + "format": "int64" + }, + "issue_field_name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "data_type": { + "type": "string", + "enum": [ + "text", + "single_select", + "multi_select", + "number", + "date" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + } + ], + "nullable": true + }, + "single_select_option": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ], + "nullable": true + }, + "multi_select_options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ] + }, + "nullable": true + } + }, + "required": [ + "issue_field_id", + "node_id", + "data_type", + "value" + ] + }, + "issue-search-result-item": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + }, + "nullable": true + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "archived_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + } + } + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "text_matches": { + "$ref": "#/components/schemas/search-result-text-matches" + }, + "pull_request": { + "type": "object", + "properties": { + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "diff_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "patch_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "diff_url", + "html_url", + "patch_url", + "url" + ] + }, + "body": { + "type": "string" + }, + "score": { + "type": "number" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "draft": { + "type": "boolean" + }, + "repository": { + "$ref": "#/components/schemas/repository" + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + } + }, + "required": [ + "assignee", + "closed_at", + "comments", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "labels", + "labels_url", + "milestone", + "number", + "repository_url", + "state", + "locked", + "title", + "url", + "user", + "author_association", + "created_at", + "updated_at", + "score" + ] + }, + "issue-type": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string", + "enum": [ + "gray", + "blue", + "green", + "yellow", + "orange", + "red", + "pink", + "purple" + ], + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "is_enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "description" + ] + }, + "label": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "archived_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "description", + "color", + "default", + "archived_at", + "archived_by" + ] + }, + "link": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + }, + "milestone": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "default": "open" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "creator": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "open_issues": { + "type": "integer" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "due_on": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "closed_issues", + "creator", + "description", + "due_on", + "closed_at", + "id", + "node_id", + "labels_url", + "html_url", + "number", + "open_issues", + "state", + "title", + "url", + "created_at", + "updated_at" + ] + }, + "minimal-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string" + }, + "homepage": { + "type": "string", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + } + }, + "role_name": { + "type": "string" + }, + "temp_clone_token": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "code_of_conduct": { + "$ref": "#/components/schemas/code-of-conduct" + }, + "license": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true + }, + "node_id": { + "type": "string" + } + }, + "nullable": true + }, + "forks": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "security_and_analysis": { + "$ref": "#/components/schemas/security-and-analysis" + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url" + ] + }, + "nullable-integration": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "owner": { + "oneOf": [ + { + "$ref": "#/components/schemas/simple-user" + }, + { + "$ref": "#/components/schemas/enterprise" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "external_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "type": "object", + "properties": { + "issues": { + "type": "string" + }, + "checks": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "contents": { + "type": "string" + }, + "deployments": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "installations_count": { + "type": "integer" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at", + "permissions", + "events" + ] + }, + "nullable-issue-comment": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "body": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "$ref": "#/components/schemas/reaction-rollup" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + } + }, + "required": [ + "id", + "node_id", + "html_url", + "issue_url", + "user", + "url", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-issue-comment-minimized": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "reason" + ], + "nullable": true + }, + "nullable-license-simple": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "spdx_id": { + "type": "string", + "nullable": true + }, + "node_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "key", + "name", + "url", + "spdx_id", + "node_id" + ], + "nullable": true + }, + "nullable-milestone": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "default": "open" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "creator": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "open_issues": { + "type": "integer" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "due_on": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "closed_issues", + "creator", + "description", + "due_on", + "closed_at", + "id", + "node_id", + "labels_url", + "html_url", + "number", + "open_issues", + "state", + "title", + "url", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-pinned-issue-comment": { + "type": "object", + "properties": { + "pinned_at": { + "type": "string", + "format": "date-time" + }, + "pinned_by": { + "$ref": "#/components/schemas/nullable-simple-user" + } + }, + "required": [ + "pinned_at", + "pinned_by" + ], + "nullable": true + }, + "nullable-repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean", + "deprecated": true + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + }, + "code_search_index_status": { + "type": "object", + "properties": { + "lexical_search_ok": { + "type": "boolean" + }, + "lexical_commit_sha": { + "type": "string" + } + } + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ], + "nullable": true + }, + "nullable-simple-user": { + "type": "object", + "properties": { + "name": { + "nullable": true, + "type": "string" + }, + "email": { + "nullable": true, + "type": "string" + }, + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_at": { + "type": "string" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url" + ], + "nullable": true + }, + "org-hook": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "insecure_ssl": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "secret": { + "type": "string" + } + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "url", + "type", + "name", + "active", + "events", + "config", + "ping_url", + "created_at", + "updated_at" + ] + }, + "organization-full": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "format": "uri", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "is_verified": { + "type": "boolean" + }, + "has_organization_projects": { + "type": "boolean" + }, + "has_repository_projects": { + "type": "boolean" + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer", + "nullable": true + }, + "disk_usage": { + "type": "integer", + "nullable": true + }, + "collaborators": { + "type": "integer", + "nullable": true + }, + "billing_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "plan": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + }, + "filled_seats": { + "type": "integer" + }, + "seats": { + "type": "integer" + } + }, + "required": [ + "name", + "space", + "private_repos" + ] + }, + "default_repository_permission": { + "type": "string", + "nullable": true + }, + "default_repository_branch": { + "type": "string", + "nullable": true + }, + "members_can_create_repositories": { + "type": "boolean", + "nullable": true + }, + "two_factor_requirement_enabled": { + "type": "boolean", + "nullable": true + }, + "members_allowed_repository_creation_type": { + "type": "string" + }, + "members_can_create_public_repositories": { + "type": "boolean" + }, + "members_can_create_private_repositories": { + "type": "boolean" + }, + "members_can_create_internal_repositories": { + "type": "boolean" + }, + "members_can_create_pages": { + "type": "boolean" + }, + "members_can_create_public_pages": { + "type": "boolean" + }, + "members_can_create_private_pages": { + "type": "boolean" + }, + "members_can_delete_repositories": { + "type": "boolean" + }, + "members_can_change_repo_visibility": { + "type": "boolean" + }, + "members_can_invite_outside_collaborators": { + "type": "boolean" + }, + "members_can_delete_issues": { + "type": "boolean" + }, + "display_commenter_full_name_setting_enabled": { + "type": "boolean" + }, + "readers_can_create_discussions": { + "type": "boolean" + }, + "members_can_create_teams": { + "type": "boolean" + }, + "members_can_view_dependency_insights": { + "type": "boolean" + }, + "members_can_fork_private_repositories": { + "type": "boolean", + "nullable": true + }, + "web_commit_signoff_required": { + "type": "boolean" + }, + "advanced_security_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependabot_alerts_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependabot_security_updates_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "dependency_graph_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_push_protection_enabled_for_new_repositories": { + "type": "boolean", + "deprecated": true + }, + "secret_scanning_push_protection_custom_link_enabled": { + "type": "boolean" + }, + "secret_scanning_push_protection_custom_link": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "archived_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deploy_keys_enabled_for_repositories": { + "type": "boolean" + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description", + "html_url", + "has_organization_projects", + "has_repository_projects", + "public_repos", + "public_gists", + "followers", + "following", + "type", + "created_at", + "updated_at", + "archived_at" + ] + }, + "organization-simple": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description" + ] + }, + "organization-simple-webhooks": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "hooks_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "members_url": { + "type": "string" + }, + "public_members_url": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "login", + "url", + "id", + "node_id", + "repos_url", + "events_url", + "hooks_url", + "issues_url", + "members_url", + "public_members_url", + "avatar_url", + "description" + ] + }, + "private-user": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "user_view_type": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "name": { + "type": "string", + "nullable": true + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "notification_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "hireable": { + "type": "boolean", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "private_gists": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "disk_usage": { + "type": "integer" + }, + "collaborators": { + "type": "integer" + }, + "two_factor_authentication": { + "type": "boolean" + }, + "plan": { + "type": "object", + "properties": { + "collaborators": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + } + }, + "required": [ + "collaborators", + "name", + "space", + "private_repos" + ] + }, + "business_plus": { + "type": "boolean" + }, + "ldap_dn": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url", + "bio", + "blog", + "company", + "email", + "followers", + "following", + "hireable", + "location", + "name", + "public_gists", + "public_repos", + "created_at", + "updated_at", + "collaborators", + "disk_usage", + "owned_private_repos", + "private_gists", + "total_private_repos", + "two_factor_authentication" + ] + }, + "public-user": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "user_view_type": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "name": { + "type": "string", + "nullable": true + }, + "company": { + "type": "string", + "nullable": true + }, + "blog": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "nullable": true + }, + "notification_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "hireable": { + "type": "boolean", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, + "twitter_username": { + "type": "string", + "nullable": true + }, + "public_repos": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "plan": { + "type": "object", + "properties": { + "collaborators": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "space": { + "type": "integer" + }, + "private_repos": { + "type": "integer" + } + }, + "required": [ + "collaborators", + "name", + "space", + "private_repos" + ] + }, + "private_gists": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "owned_private_repos": { + "type": "integer" + }, + "disk_usage": { + "type": "integer" + }, + "collaborators": { + "type": "integer" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url", + "bio", + "blog", + "company", + "email", + "followers", + "following", + "hireable", + "location", + "name", + "public_gists", + "public_repos", + "created_at", + "updated_at" + ], + "additionalProperties": false + }, + "pull-request": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "review_comment_url": { + "type": "string" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "title": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + }, + "body": { + "type": "string", + "nullable": true + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "archived_by": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/simple-user" + } + ] + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "description", + "color", + "default", + "archived_at", + "archived_by" + ] + } + }, + "milestone": { + "$ref": "#/components/schemas/nullable-milestone" + }, + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "merged_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "assignee": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "requested_reviewers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/simple-user" + } + }, + "requested_teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/team-simple" + } + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "$ref": "#/components/schemas/repository" + }, + "sha": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "label", + "ref", + "repo", + "sha", + "user" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "$ref": "#/components/schemas/repository" + }, + "sha": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "label", + "ref", + "repo", + "sha", + "user" + ] + }, + "_links": { + "type": "object", + "properties": { + "comments": { + "$ref": "#/components/schemas/link" + }, + "commits": { + "$ref": "#/components/schemas/link" + }, + "statuses": { + "$ref": "#/components/schemas/link" + }, + "html": { + "$ref": "#/components/schemas/link" + }, + "issue": { + "$ref": "#/components/schemas/link" + }, + "review_comments": { + "$ref": "#/components/schemas/link" + }, + "review_comment": { + "$ref": "#/components/schemas/link" + }, + "self": { + "$ref": "#/components/schemas/link" + } + }, + "required": [ + "comments", + "commits", + "statuses", + "html", + "issue", + "review_comments", + "review_comment", + "self" + ] + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + }, + "auto_merge": { + "$ref": "#/components/schemas/auto-merge" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "draft": { + "type": "boolean" + }, + "merged": { + "type": "boolean" + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged_by": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "comments": { + "type": "integer" + }, + "review_comments": { + "type": "integer" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "commits": { + "type": "integer" + }, + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "changed_files": { + "type": "integer" + } + }, + "required": [ + "_links", + "assignee", + "labels", + "base", + "body", + "closed_at", + "comments_url", + "commits_url", + "created_at", + "diff_url", + "head", + "html_url", + "id", + "node_id", + "issue_url", + "merge_commit_sha", + "merged_at", + "milestone", + "number", + "patch_url", + "review_comment_url", + "review_comments_url", + "statuses_url", + "state", + "locked", + "title", + "updated_at", + "url", + "user", + "author_association", + "auto_merge", + "additions", + "changed_files", + "comments", + "commits", + "deletions", + "mergeable", + "mergeable_state", + "merged", + "maintainer_can_modify", + "merged_by", + "review_comments" + ] + }, + "pull-request-review": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "body": { + "type": "string" + }, + "state": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "pull_request_url": { + "type": "string", + "format": "uri" + }, + "_links": { + "type": "object", + "properties": { + "html": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "html", + "pull_request" + ] + }, + "submitted_at": { + "type": "string", + "format": "date-time" + }, + "commit_id": { + "type": "string", + "nullable": true + }, + "body_html": { + "type": "string" + }, + "body_text": { + "type": "string" + }, + "author_association": { + "$ref": "#/components/schemas/author-association" + } + }, + "required": [ + "id", + "node_id", + "user", + "body", + "state", + "commit_id", + "html_url", + "pull_request_url", + "_links", + "author_association" + ] + }, + "pull-request-stack": { + "type": "object", + "properties": { + "base": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ] + }, + "size": { + "type": "integer" + }, + "position": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "number": { + "type": "integer" + } + }, + "required": [ + "base" + ], + "nullable": true + }, + "pull-request-webhook": { + "allOf": [ + { + "$ref": "#/components/schemas/pull-request" + }, + { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_update_branch": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + } + } + } + ] + }, + "reaction-rollup": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "total_count": { + "type": "integer" + }, + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "rocket": { + "type": "integer" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean", + "deprecated": true + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + }, + "code_search_index_status": { + "type": "object", + "properties": { + "lexical_search_ok": { + "type": "boolean" + }, + "lexical_commit_sha": { + "type": "string" + } + } + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ] + }, + "repository-webhooks": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "license": { + "$ref": "#/components/schemas/nullable-license-simple" + }, + "organization": { + "$ref": "#/components/schemas/nullable-simple-user" + }, + "forks": { + "type": "integer" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + } + }, + "required": [ + "admin", + "pull", + "push" + ] + }, + "owner": { + "$ref": "#/components/schemas/simple-user" + }, + "private": { + "default": false, + "type": "boolean" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "homepage": { + "type": "string", + "format": "uri", + "nullable": true + }, + "language": { + "type": "string", + "nullable": true + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "default": false, + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "has_issues": { + "default": true, + "type": "boolean" + }, + "has_projects": { + "default": true, + "type": "boolean" + }, + "has_wiki": { + "default": true, + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "default": true, + "type": "boolean" + }, + "has_discussions": { + "default": false, + "type": "boolean" + }, + "has_pull_requests": { + "default": true, + "type": "boolean" + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "archived": { + "default": false, + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "default": "public", + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "allow_rebase_merge": { + "default": true, + "type": "boolean" + }, + "template_repository": { + "nullable": true, + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "owner": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "gravatar_id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "followers_url": { + "type": "string" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string" + }, + "organizations_url": { + "type": "string" + }, + "repos_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + } + } + }, + "private": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fork": { + "type": "boolean" + }, + "url": { + "type": "string" + }, + "archive_url": { + "type": "string" + }, + "assignees_url": { + "type": "string" + }, + "blobs_url": { + "type": "string" + }, + "branches_url": { + "type": "string" + }, + "collaborators_url": { + "type": "string" + }, + "comments_url": { + "type": "string" + }, + "commits_url": { + "type": "string" + }, + "compare_url": { + "type": "string" + }, + "contents_url": { + "type": "string" + }, + "contributors_url": { + "type": "string" + }, + "deployments_url": { + "type": "string" + }, + "downloads_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "forks_url": { + "type": "string" + }, + "git_commits_url": { + "type": "string" + }, + "git_refs_url": { + "type": "string" + }, + "git_tags_url": { + "type": "string" + }, + "git_url": { + "type": "string" + }, + "issue_comment_url": { + "type": "string" + }, + "issue_events_url": { + "type": "string" + }, + "issues_url": { + "type": "string" + }, + "keys_url": { + "type": "string" + }, + "labels_url": { + "type": "string" + }, + "languages_url": { + "type": "string" + }, + "merges_url": { + "type": "string" + }, + "milestones_url": { + "type": "string" + }, + "notifications_url": { + "type": "string" + }, + "pulls_url": { + "type": "string" + }, + "releases_url": { + "type": "string" + }, + "ssh_url": { + "type": "string" + }, + "stargazers_url": { + "type": "string" + }, + "statuses_url": { + "type": "string" + }, + "subscribers_url": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "tags_url": { + "type": "string" + }, + "teams_url": { + "type": "string" + }, + "trees_url": { + "type": "string" + }, + "clone_url": { + "type": "string" + }, + "mirror_url": { + "type": "string" + }, + "hooks_url": { + "type": "string" + }, + "svn_url": { + "type": "string" + }, + "homepage": { + "type": "string" + }, + "language": { + "type": "string" + }, + "forks_count": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "default_branch": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "is_template": { + "type": "boolean" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_issues": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean" + }, + "has_wiki": { + "type": "boolean" + }, + "has_pages": { + "type": "boolean" + }, + "has_downloads": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "visibility": { + "type": "string" + }, + "pushed_at": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + } + }, + "allow_rebase_merge": { + "type": "boolean" + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "type": "boolean" + }, + "allow_auto_merge": { + "type": "boolean" + }, + "delete_branch_on_merge": { + "type": "boolean" + }, + "allow_update_branch": { + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean" + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + } + } + }, + "temp_clone_token": { + "type": "string" + }, + "allow_squash_merge": { + "default": true, + "type": "boolean" + }, + "allow_auto_merge": { + "default": false, + "type": "boolean" + }, + "delete_branch_on_merge": { + "default": false, + "type": "boolean" + }, + "allow_update_branch": { + "default": false, + "type": "boolean" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false, + "deprecated": true + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "allow_merge_commit": { + "default": true, + "type": "boolean" + }, + "allow_forking": { + "type": "boolean" + }, + "web_commit_signoff_required": { + "default": false, + "type": "boolean" + }, + "subscribers_count": { + "type": "integer" + }, + "network_count": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "watchers": { + "type": "integer" + }, + "master_branch": { + "type": "string" + }, + "starred_at": { + "type": "string" + }, + "anonymous_access_enabled": { + "type": "boolean" + } + }, + "required": [ + "archive_url", + "assignees_url", + "blobs_url", + "branches_url", + "collaborators_url", + "comments_url", + "commits_url", + "compare_url", + "contents_url", + "contributors_url", + "deployments_url", + "description", + "downloads_url", + "events_url", + "fork", + "forks_url", + "full_name", + "git_commits_url", + "git_refs_url", + "git_tags_url", + "hooks_url", + "html_url", + "id", + "node_id", + "issue_comment_url", + "issue_events_url", + "issues_url", + "keys_url", + "labels_url", + "languages_url", + "merges_url", + "milestones_url", + "name", + "notifications_url", + "owner", + "private", + "pulls_url", + "releases_url", + "stargazers_url", + "statuses_url", + "subscribers_url", + "subscription_url", + "tags_url", + "teams_url", + "trees_url", + "url", + "clone_url", + "default_branch", + "forks", + "forks_count", + "git_url", + "has_downloads", + "has_issues", + "has_projects", + "has_wiki", + "has_pages", + "homepage", + "language", + "archived", + "disabled", + "mirror_url", + "open_issues", + "open_issues_count", + "license", + "pushed_at", + "size", + "ssh_url", + "stargazers_count", + "svn_url", + "watchers", + "watchers_count", + "created_at", + "updated_at" + ] + }, + "scim-error": { + "type": "object", + "properties": { + "message": { + "type": "string", + "nullable": true + }, + "documentation_url": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer" + }, + "scimType": { + "type": "string", + "nullable": true + }, + "schemas": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "search-result-text-matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "object_url": { + "type": "string" + }, + "object_type": { + "nullable": true, + "type": "string" + }, + "property": { + "type": "string" + }, + "fragment": { + "type": "string" + }, + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "indices": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + } + }, + "security-and-analysis": { + "nullable": true, + "type": "object", + "properties": { + "advanced_security": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "code_security": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "dependabot_security_updates": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_push_protection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_non_provider_patterns": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_ai_detection": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "properties": { + "reviewers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer" + }, + "reviewer_type": { + "type": "string", + "enum": [ + "TEAM", + "ROLE" + ] + }, + "mode": { + "type": "string", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" + } + } + } + } + } + } + } + }, + "simple-commit-status": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "context": { + "type": "string" + }, + "target_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "required": { + "type": "boolean", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "node_id", + "state", + "context", + "target_url", + "avatar_url", + "url", + "created_at", + "updated_at" + ] + }, + "simple-installation": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "id", + "node_id" + ] + }, + "simple-user": { + "type": "object", + "properties": { + "name": { + "nullable": true, + "type": "string" + }, + "email": { + "nullable": true, + "type": "string" + }, + "login": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "node_id": { + "type": "string" + }, + "avatar_url": { + "type": "string", + "format": "uri" + }, + "gravatar_id": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_at": { + "type": "string" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "avatar_url", + "events_url", + "followers_url", + "following_url", + "gists_url", + "gravatar_id", + "html_url", + "id", + "node_id", + "login", + "organizations_url", + "received_events_url", + "repos_url", + "site_admin", + "starred_url", + "subscriptions_url", + "type", + "url" + ] + }, + "sub-issues-summary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "percent_completed": { + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ] + }, + "team-simple": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "members_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string" + }, + "notification_setting": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "ldap_dn": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enterprise", + "organization" + ] + }, + "organization_id": { + "type": "integer" + }, + "enterprise_id": { + "type": "integer" + } + }, + "required": [ + "id", + "node_id", + "url", + "members_url", + "name", + "description", + "permission", + "html_url", + "repositories_url", + "slug", + "type" + ] + }, + "thread": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "repository": { + "$ref": "#/components/schemas/minimal-repository" + }, + "subject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "latest_comment_url": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "title", + "url", + "latest_comment_url", + "type" + ] + }, + "reason": { + "type": "string" + }, + "unread": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "last_read_at": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string" + }, + "subscription_url": { + "type": "string" + } + }, + "required": [ + "id", + "unread", + "reason", + "updated_at", + "last_read_at", + "subject", + "repository", + "url", + "subscription_url" + ] + }, + "validation-error": { + "type": "object", + "required": [ + "message", + "documentation_url" + ], + "properties": { + "message": { + "type": "string" + }, + "documentation_url": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "resource": { + "type": "string" + }, + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "index": { + "type": "integer" + }, + "value": { + "oneOf": [ + { + "type": "string", + "nullable": true + }, + { + "type": "integer", + "nullable": true + }, + { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + } + ] + } + } + } + } + } + }, + "webhook-config": { + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhook-config-url" + }, + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + } + } + }, + "webhook-config-content-type": { + "type": "string" + }, + "webhook-config-insecure-ssl": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "webhook-config-secret": { + "type": "string" + }, + "webhook-config-url": { + "type": "string", + "format": "uri" + }, + "webhook-issue-comment-created": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "created" + ] + }, + "comment": { + "type": "object", + "properties": { + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "performed_via_github_app": { + "$ref": "#/components/schemas/nullable-integration" + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pin": { + "$ref": "#/components/schemas/nullable-pinned-issue-comment" + }, + "minimized": { + "$ref": "#/components/schemas/nullable-issue-comment-minimized" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "html_url", + "issue_url", + "id", + "node_id", + "user", + "created_at", + "updated_at", + "author_association", + "performed_via_github_app", + "body", + "reactions" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "issue": { + "allOf": [ + { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run", + "reminder", + "pull_request_review_thread" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "repository_url", + "labels_url", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "number", + "title", + "user", + "assignees", + "milestone", + "comments", + "created_at", + "updated_at", + "closed_at", + "author_association", + "active_lock_reason", + "body", + "reactions" + ] + }, + { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true + } + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string" + } + } + }, + "repository_url": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "timeline_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "events_url": { + "type": "string" + }, + "followers_url": { + "type": "string" + }, + "following_url": { + "type": "string" + }, + "gists_url": { + "type": "string" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string" + }, + "received_events_url": { + "type": "string" + }, + "repos_url": { + "type": "string" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string" + }, + "subscriptions_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + }, + "required": [ + "labels", + "state", + "locked", + "assignee" + ] + } + ] + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "issue", + "comment", + "repository", + "sender" + ] + }, + "webhook-issues-opened": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "opened" + ] + }, + "changes": { + "type": "object", + "properties": { + "old_issue": { + "type": "object", + "nullable": true, + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "type": { + "$ref": "#/components/schemas/issue-type" + } + }, + "required": [ + "id", + "number" + ] + }, + "old_repository": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_discussions": { + "type": "boolean" + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + } + }, + "required": [ + "old_issue", + "old_repository" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "issue": { + "type": "object", + "properties": { + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "draft": { + "type": "boolean" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "performed_via_github_app": { + "type": "object", + "nullable": true, + "properties": { + "created_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "events": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch_protection_rule", + "check_run", + "check_suite", + "code_scanning_alert", + "commit_comment", + "content_reference", + "create", + "delete", + "deployment", + "deployment_review", + "deployment_status", + "deploy_key", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issues", + "issue_comment", + "label", + "member", + "membership", + "milestone", + "organization", + "org_block", + "page_build", + "project", + "project_card", + "project_column", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "registry_package", + "release", + "repository", + "repository_dispatch", + "secret_scanning_alert", + "star", + "status", + "team", + "team_add", + "watch", + "workflow_dispatch", + "workflow_run", + "security_and_analysis", + "pull_request_review_thread", + "reminder" + ] + } + }, + "external_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "actions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "checks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "content_references": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "contents": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "deployments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "discussions": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "emails": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "environments": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "issues": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "keys": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "members": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "metadata": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_administration": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_plan": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_projects": { + "type": "string", + "enum": [ + "read", + "write", + "admin" + ] + }, + "organization_secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_self_hosted_runners": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "organization_user_blocking": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "packages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pages": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "pull_requests": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_hooks": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "repository_projects": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secret_scanning_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "secrets": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_events": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "security_scanning_alert": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "single_file": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "statuses": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "vulnerability_alerts": { + "type": "string", + "enum": [ + "read", + "write" + ] + }, + "workflows": { + "type": "string", + "enum": [ + "read", + "write" + ] + } + } + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "node_id", + "owner", + "name", + "description", + "external_url", + "html_url", + "created_at", + "updated_at" + ] + }, + "pull_request": { + "type": "object", + "properties": { + "diff_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "url": { + "type": "string", + "format": "uri" + } + } + }, + "reactions": { + "type": "object", + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "eyes", + "rocket" + ] + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "sub_issues_summary": { + "$ref": "#/components/schemas/sub-issues-summary" + }, + "issue_dependencies_summary": { + "$ref": "#/components/schemas/issue-dependencies-summary" + }, + "issue_field_values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/issue-field-value" + } + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "state_reason": { + "type": "string", + "nullable": true + }, + "timeline_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/issue-type" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "pinned_comment": { + "$ref": "#/components/schemas/nullable-issue-comment" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "repository_url", + "labels_url", + "comments_url", + "events_url", + "html_url", + "id", + "node_id", + "number", + "title", + "user", + "assignees", + "milestone", + "comments", + "created_at", + "updated_at", + "closed_at", + "author_association", + "active_lock_reason", + "body", + "reactions" + ] + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "issue", + "repository", + "sender" + ] + }, + "webhook-ping": { + "type": "object", + "properties": { + "hook": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "app_id": { + "type": "integer" + }, + "config": { + "type": "object", + "properties": { + "content_type": { + "$ref": "#/components/schemas/webhook-config-content-type" + }, + "insecure_ssl": { + "$ref": "#/components/schemas/webhook-config-insecure-ssl" + }, + "secret": { + "$ref": "#/components/schemas/webhook-config-secret" + }, + "url": { + "$ref": "#/components/schemas/webhook-config-url" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deliveries_url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "last_response": { + "$ref": "#/components/schemas/hook-response" + }, + "name": { + "type": "string", + "enum": [ + "web" + ] + }, + "ping_url": { + "type": "string", + "format": "uri" + }, + "test_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "type", + "name", + "active", + "events", + "config", + "created_at", + "updated_at" + ] + }, + "hook_id": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + }, + "zen": { + "type": "string" + } + } + }, + "webhook-ping-form-encoded": { + "type": "object", + "properties": { + "payload": { + "type": "string" + } + }, + "required": [ + "payload" + ] + }, + "webhook-pull-request-closed": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "closed" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "$ref": "#/components/schemas/webhooks_number" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "$ref": "#/components/schemas/pull-request-webhook" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + "webhook-pull-request-opened": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "opened" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "$ref": "#/components/schemas/webhooks_number" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "$ref": "#/components/schemas/pull-request-webhook" + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + "webhook-pull-request-review-requested": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "review_requested" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "commits": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "html": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "issue": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comment": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "statuses": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "self", + "html", + "issue", + "comments", + "review_comments", + "review_comment", + "commits", + "statuses" + ] + }, + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "additions": { + "type": "integer" + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto_merge": { + "type": "object", + "nullable": true, + "properties": { + "commit_message": { + "type": "string", + "nullable": true + }, + "commit_title": { + "type": "string", + "nullable": true + }, + "enabled_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "commits": { + "type": "integer" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deletions": { + "type": "integer" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "draft": { + "type": "boolean" + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "locked": { + "type": "boolean" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "type": "boolean", + "nullable": true + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "merged_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "requested_reviewers": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + } + ] + } + }, + "requested_teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + }, + "review_comment_url": { + "type": "string", + "format": "uri-template" + }, + "review_comments": { + "type": "integer" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "id", + "node_id", + "html_url", + "diff_url", + "patch_url", + "issue_url", + "number", + "state", + "locked", + "title", + "user", + "body", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "merge_commit_sha", + "assignee", + "assignees", + "requested_reviewers", + "requested_teams", + "labels", + "milestone", + "commits_url", + "review_comments_url", + "review_comment_url", + "comments_url", + "statuses_url", + "head", + "base", + "_links", + "author_association", + "auto_merge", + "active_lock_reason", + "draft" + ] + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "requested_reviewer": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "requested_reviewer", + "action", + "number", + "pull_request", + "repository", + "sender" + ] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "review_requested" + ] + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "number": { + "type": "integer" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pull_request": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "commits": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "html": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "issue": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comment": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "review_comments": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + }, + "statuses": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "uri-template" + } + }, + "required": [ + "href" + ] + } + }, + "required": [ + "self", + "html", + "issue", + "comments", + "review_comments", + "review_comment", + "commits", + "statuses" + ] + }, + "active_lock_reason": { + "type": "string", + "nullable": true, + "enum": [ + "resolved", + "off-topic", + "too heated", + "spam", + null + ] + }, + "additions": { + "type": "integer" + }, + "assignee": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "author_association": { + "type": "string", + "enum": [ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER" + ] + }, + "auto_merge": { + "type": "object", + "nullable": true, + "properties": { + "commit_message": { + "type": "string", + "nullable": true + }, + "commit_title": { + "type": "string", + "nullable": true + }, + "enabled_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "merge_method": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "required": [ + "enabled_by", + "merge_method", + "commit_title", + "commit_message" + ] + }, + "base": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "body": { + "type": "string", + "nullable": true + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "comments": { + "type": "integer" + }, + "comments_url": { + "type": "string", + "format": "uri" + }, + "commits": { + "type": "integer" + }, + "commits_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deletions": { + "type": "integer" + }, + "diff_url": { + "type": "string", + "format": "uri" + }, + "draft": { + "type": "boolean" + }, + "head": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "repo": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "PR_TITLE", + "BLANK" + ] + }, + "merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "MERGE_MESSAGE" + ] + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "squash_merge_commit_message": { + "type": "string", + "enum": [ + "PR_BODY", + "COMMIT_MESSAGES", + "BLANK" + ] + }, + "squash_merge_commit_title": { + "type": "string", + "enum": [ + "PR_TITLE", + "COMMIT_OR_PR_TITLE" + ] + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "use_squash_pr_title_as_default": { + "type": "boolean", + "default": false + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sha": { + "type": "string" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "label", + "ref", + "sha", + "user", + "repo" + ] + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "issue_url": { + "type": "string", + "format": "uri" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "default": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "node_id", + "url", + "name", + "color", + "default", + "description" + ] + } + }, + "locked": { + "type": "boolean" + }, + "maintainer_can_modify": { + "type": "boolean" + }, + "merge_commit_sha": { + "type": "string", + "nullable": true + }, + "mergeable": { + "type": "boolean", + "nullable": true + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "type": "boolean", + "nullable": true + }, + "merged_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "merged_by": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "milestone": { + "type": "object", + "nullable": true, + "properties": { + "closed_at": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closed_issues": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "creator": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "description": { + "type": "string", + "nullable": true + }, + "due_on": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "labels_url": { + "type": "string", + "format": "uri" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "open_issues": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url", + "html_url", + "labels_url", + "id", + "node_id", + "number", + "title", + "description", + "creator", + "open_issues", + "closed_issues", + "state", + "created_at", + "updated_at", + "due_on", + "closed_at" + ] + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "patch_url": { + "type": "string", + "format": "uri" + }, + "rebaseable": { + "type": "boolean", + "nullable": true + }, + "requested_reviewers": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + ] + } + }, + "requested_teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + } + }, + "review_comment_url": { + "type": "string", + "format": "uri-template" + }, + "review_comments": { + "type": "integer" + }, + "review_comments_url": { + "type": "string", + "format": "uri" + }, + "stack": { + "$ref": "#/components/schemas/pull-request-stack" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "statuses_url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "user": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization", + "Mannequin" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + } + }, + "required": [ + "url", + "id", + "node_id", + "html_url", + "diff_url", + "patch_url", + "issue_url", + "number", + "state", + "locked", + "title", + "user", + "body", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "merge_commit_sha", + "assignee", + "assignees", + "requested_reviewers", + "requested_teams", + "labels", + "milestone", + "commits_url", + "review_comments_url", + "review_comment_url", + "comments_url", + "statuses_url", + "head", + "base", + "_links", + "author_association", + "auto_merge", + "active_lock_reason", + "draft" + ] + }, + "repository": { + "$ref": "#/components/schemas/repository-webhooks" + }, + "requested_team": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "parent": { + "type": "object", + "nullable": true, + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "members_url": { + "type": "string", + "format": "uri-template" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id", + "node_id", + "slug", + "description", + "privacy", + "url", + "html_url", + "members_url", + "repositories_url", + "permission" + ] + }, + "permission": { + "type": "string" + }, + "privacy": { + "type": "string", + "enum": [ + "open", + "closed", + "secret" + ] + }, + "repositories_url": { + "type": "string", + "format": "uri" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "id" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "requested_team", + "action", + "number", + "pull_request", + "repository", + "sender" + ] + } + ] + }, + "webhook-push": { + "type": "object", + "properties": { + "after": { + "type": "string" + }, + "base_ref": { + "$ref": "#/components/schemas/webhooks_nullable_string" + }, + "before": { + "type": "string" + }, + "commits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "committer": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "distinct": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "modified": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "tree_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "tree_id", + "distinct", + "message", + "timestamp", + "url", + "author", + "committer" + ] + } + }, + "compare": { + "type": "string" + }, + "created": { + "type": "boolean" + }, + "deleted": { + "type": "boolean" + }, + "enterprise": { + "$ref": "#/components/schemas/enterprise-webhooks" + }, + "forced": { + "type": "boolean" + }, + "head_commit": { + "type": "object", + "nullable": true, + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "committer": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "email", + "name" + ] + }, + "distinct": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "modified": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "tree_id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "tree_id", + "distinct", + "message", + "timestamp", + "url", + "author", + "committer" + ] + }, + "installation": { + "$ref": "#/components/schemas/simple-installation" + }, + "organization": { + "$ref": "#/components/schemas/organization-simple-webhooks" + }, + "pusher": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "nullable": true, + "format": "email" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "ref": { + "type": "string" + }, + "repository": { + "type": "object", + "properties": { + "allow_auto_merge": { + "type": "boolean", + "default": false + }, + "allow_forking": { + "type": "boolean" + }, + "allow_merge_commit": { + "type": "boolean", + "default": true + }, + "allow_rebase_merge": { + "type": "boolean", + "default": true + }, + "allow_squash_merge": { + "type": "boolean", + "default": true + }, + "allow_update_branch": { + "type": "boolean" + }, + "archive_url": { + "type": "string", + "format": "uri-template" + }, + "archived": { + "type": "boolean", + "default": false + }, + "assignees_url": { + "type": "string", + "format": "uri-template" + }, + "blobs_url": { + "type": "string", + "format": "uri-template" + }, + "branches_url": { + "type": "string", + "format": "uri-template" + }, + "clone_url": { + "type": "string", + "format": "uri" + }, + "collaborators_url": { + "type": "string", + "format": "uri-template" + }, + "comments_url": { + "type": "string", + "format": "uri-template" + }, + "commits_url": { + "type": "string", + "format": "uri-template" + }, + "compare_url": { + "type": "string", + "format": "uri-template" + }, + "contents_url": { + "type": "string", + "format": "uri-template" + }, + "contributors_url": { + "type": "string", + "format": "uri" + }, + "created_at": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "custom_properties": { + "type": "object", + "additionalProperties": true + }, + "default_branch": { + "type": "string" + }, + "delete_branch_on_merge": { + "type": "boolean", + "default": false + }, + "deployments_url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "nullable": true + }, + "disabled": { + "type": "boolean" + }, + "downloads_url": { + "type": "string", + "format": "uri" + }, + "events_url": { + "type": "string", + "format": "uri" + }, + "fork": { + "type": "boolean" + }, + "forks": { + "type": "integer" + }, + "forks_count": { + "type": "integer" + }, + "forks_url": { + "type": "string", + "format": "uri" + }, + "full_name": { + "type": "string" + }, + "git_commits_url": { + "type": "string", + "format": "uri-template" + }, + "git_refs_url": { + "type": "string", + "format": "uri-template" + }, + "git_tags_url": { + "type": "string", + "format": "uri-template" + }, + "git_url": { + "type": "string", + "format": "uri" + }, + "has_downloads": { + "type": "boolean", + "default": true + }, + "has_issues": { + "type": "boolean", + "default": true + }, + "has_pages": { + "type": "boolean" + }, + "has_projects": { + "type": "boolean", + "default": true + }, + "has_wiki": { + "type": "boolean", + "default": true + }, + "has_discussions": { + "type": "boolean", + "default": false + }, + "has_pull_requests": { + "type": "boolean", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "enum": [ + "all", + "collaborators_only" + ] + }, + "homepage": { + "type": "string", + "nullable": true + }, + "hooks_url": { + "type": "string", + "format": "uri" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "is_template": { + "type": "boolean" + }, + "issue_comment_url": { + "type": "string", + "format": "uri-template" + }, + "issue_events_url": { + "type": "string", + "format": "uri-template" + }, + "issues_url": { + "type": "string", + "format": "uri-template" + }, + "keys_url": { + "type": "string", + "format": "uri-template" + }, + "labels_url": { + "type": "string", + "format": "uri-template" + }, + "language": { + "type": "string", + "nullable": true + }, + "languages_url": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "object", + "nullable": true, + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "spdx_id": { + "type": "string" + }, + "url": { + "type": "string", + "nullable": true, + "format": "uri" + } + }, + "required": [ + "key", + "name", + "spdx_id", + "url", + "node_id" + ] + }, + "master_branch": { + "type": "string" + }, + "merges_url": { + "type": "string", + "format": "uri" + }, + "milestones_url": { + "type": "string", + "format": "uri-template" + }, + "mirror_url": { + "type": "string", + "nullable": true, + "format": "uri" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "notifications_url": { + "type": "string", + "format": "uri-template" + }, + "open_issues": { + "type": "integer" + }, + "open_issues_count": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "owner": { + "type": "object", + "nullable": true, + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "type": "string", + "nullable": true + }, + "events_url": { + "type": "string", + "format": "uri-template" + }, + "followers_url": { + "type": "string", + "format": "uri" + }, + "following_url": { + "type": "string", + "format": "uri-template" + }, + "gists_url": { + "type": "string", + "format": "uri-template" + }, + "gravatar_id": { + "type": "string" + }, + "html_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "organizations_url": { + "type": "string", + "format": "uri" + }, + "received_events_url": { + "type": "string", + "format": "uri" + }, + "repos_url": { + "type": "string", + "format": "uri" + }, + "site_admin": { + "type": "boolean" + }, + "starred_url": { + "type": "string", + "format": "uri-template" + }, + "subscriptions_url": { + "type": "string", + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "Bot", + "User", + "Organization" + ] + }, + "url": { + "type": "string", + "format": "uri" + }, + "user_view_type": { + "type": "string" + } + }, + "required": [ + "login", + "id" + ] + }, + "permissions": { + "type": "object", + "properties": { + "admin": { + "type": "boolean" + }, + "maintain": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + } + }, + "required": [ + "pull", + "push", + "admin" + ] + }, + "private": { + "type": "boolean" + }, + "public": { + "type": "boolean" + }, + "pulls_url": { + "type": "string", + "format": "uri-template" + }, + "pushed_at": { + "nullable": true, + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "releases_url": { + "type": "string", + "format": "uri-template" + }, + "role_name": { + "type": "string", + "nullable": true + }, + "size": { + "type": "integer" + }, + "ssh_url": { + "type": "string" + }, + "stargazers": { + "type": "integer" + }, + "stargazers_count": { + "type": "integer" + }, + "stargazers_url": { + "type": "string", + "format": "uri" + }, + "statuses_url": { + "type": "string", + "format": "uri-template" + }, + "subscribers_url": { + "type": "string", + "format": "uri" + }, + "subscription_url": { + "type": "string", + "format": "uri" + }, + "svn_url": { + "type": "string", + "format": "uri" + }, + "tags_url": { + "type": "string", + "format": "uri" + }, + "teams_url": { + "type": "string", + "format": "uri" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "trees_url": { + "type": "string", + "format": "uri-template" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "url": { + "type": "string", + "format": "uri" + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "internal" + ] + }, + "watchers": { + "type": "integer" + }, + "watchers_count": { + "type": "integer" + }, + "web_commit_signoff_required": { + "type": "boolean" + } + }, + "required": [ + "id", + "node_id", + "name", + "full_name", + "private", + "owner", + "html_url", + "description", + "fork", + "url", + "forks_url", + "keys_url", + "collaborators_url", + "teams_url", + "hooks_url", + "issue_events_url", + "events_url", + "assignees_url", + "branches_url", + "tags_url", + "blobs_url", + "git_tags_url", + "git_refs_url", + "trees_url", + "statuses_url", + "languages_url", + "stargazers_url", + "contributors_url", + "subscribers_url", + "subscription_url", + "commits_url", + "git_commits_url", + "comments_url", + "issue_comment_url", + "contents_url", + "compare_url", + "merges_url", + "archive_url", + "downloads_url", + "issues_url", + "pulls_url", + "milestones_url", + "notifications_url", + "labels_url", + "releases_url", + "deployments_url", + "created_at", + "updated_at", + "pushed_at", + "git_url", + "ssh_url", + "clone_url", + "svn_url", + "homepage", + "size", + "stargazers_count", + "watchers_count", + "language", + "has_issues", + "has_projects", + "has_downloads", + "has_wiki", + "has_pages", + "has_discussions", + "forks_count", + "mirror_url", + "archived", + "open_issues_count", + "license", + "forks", + "open_issues", + "watchers", + "default_branch", + "topics", + "visibility" + ] + }, + "sender": { + "$ref": "#/components/schemas/simple-user" + } + }, + "required": [ + "ref", + "before", + "after", + "created", + "deleted", + "forced", + "base_ref", + "compare", + "commits", + "head_commit", + "repository", + "pusher" + ] + }, + "webhooks_nullable_string": { + "type": "string", + "nullable": true + }, + "webhooks_number": { + "type": "integer" + } + } + } +} diff --git a/e2e/integration/fake-github/contract/specs/schema.graphql b/e2e/integration/fake-github/contract/specs/schema.graphql new file mode 100644 index 000000000..263b2a77f --- /dev/null +++ b/e2e/integration/fake-github/contract/specs/schema.graphql @@ -0,0 +1,153 @@ +interface Actor { + login: String! +} + +enum CommentAuthorAssociation { + COLLABORATOR + CONTRIBUTOR + FIRST_TIMER + FIRST_TIME_CONTRIBUTOR + MANNEQUIN + MEMBER + NONE + OWNER +} + +scalar DateTime + +type Issue { + author: Actor + authorAssociation: CommentAuthorAssociation! + body: String! + createdAt: DateTime! + labels(after: String, before: String, first: Int, last: Int, orderBy: LabelOrder = {direction: ASC, field: CREATED_AT}): LabelConnection + milestone: Milestone + number: Int! + repository: Repository! + state: IssueState! + title: String! + updatedAt: DateTime! + url: URI! +} + +enum IssueState { + CLOSED + OPEN +} + +type Label { + color: String! + name: String! +} + +type LabelConnection { + nodes: [Label] +} + +input LabelOrder { + direction: OrderDirection! + field: LabelOrderField! +} + +enum LabelOrderField { + CREATED_AT + ISSUE_COUNT + NAME +} + +type Milestone { + title: String! +} + +enum OrderDirection { + ASC + DESC +} + +type Organization implements Actor & RepositoryOwner { + login: String! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! +} + +type PullRequest { + additions: Int! + author: Actor + authorAssociation: CommentAuthorAssociation! + body: String! + changedFiles: Int! + createdAt: DateTime! + deletions: Int! + labels(after: String, before: String, first: Int, last: Int, orderBy: LabelOrder = {direction: ASC, field: CREATED_AT}): LabelConnection + milestone: Milestone + number: Int! + repository: Repository! + reviewRequests(after: String, before: String, first: Int, last: Int): ReviewRequestConnection + state: PullRequestState! + title: String! + updatedAt: DateTime! + url: URI! +} + +enum PullRequestState { + CLOSED + MERGED + OPEN +} + +type Query { + search(after: String, before: String, first: Int, last: Int, query: String!, type: SearchType!): SearchResultItemConnection! +} + +type Repository { + name: String! + nameWithOwner: String! + owner: RepositoryOwner! + url: URI! +} + +interface RepositoryOwner { + login: String! +} + +union RequestedReviewer = Team | User + +type ReviewRequest { + requestedReviewer: RequestedReviewer +} + +type ReviewRequestConnection { + nodes: [ReviewRequest] +} + +union SearchResultItem = Issue | Organization | PullRequest | Repository | User + +type SearchResultItemConnection { + issueCount: Int! + nodes: [SearchResultItem] + pageInfo: PageInfo! +} + +enum SearchType { + DISCUSSION + ISSUE + ISSUE_ADVANCED + ISSUE_HYBRID + ISSUE_SEMANTIC + REPOSITORY + USER +} + +type Team { + organization: Organization! + slug: String! +} + +scalar URI + +type User implements Actor & RepositoryOwner { + login: String! +} diff --git a/e2e/integration/fake-github/contract/update_specs.mjs b/e2e/integration/fake-github/contract/update_specs.mjs new file mode 100644 index 000000000..5cba3dcc3 --- /dev/null +++ b/e2e/integration/fake-github/contract/update_specs.mjs @@ -0,0 +1,193 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Trims GitHub's published REST description (OpenAPI, with webhook payloads) and GraphQL schema +// down to what fake-github serves. +// node update_specs.mjs rewrite specs/ from upstream +// node update_specs.mjs --check exit 1 if upstream drifted from the committed copies + +/* eslint-disable no-console */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {Kind, assertValidSchema, buildASTSchema, lexicographicSortSchema, parse, printSchema, specifiedScalarTypes} from 'graphql'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const specsDir = path.join(dir, 'specs'); +const check = process.argv.includes('--check'); +const manifest = JSON.parse(await fs.readFile(path.join(dir, 'operations.json'), 'utf8')); + +const outputs = { + 'rest.json': `${JSON.stringify(trimRest(await download(manifest.rest.url, 'json'), manifest.rest), null, 2)}\n`, + 'schema.graphql': trimGraphQL(await download(manifest.graphql.url, 'text'), manifest.graphql.fields), +}; + +let drifted = false; +for (const [file, next] of Object.entries(outputs)) { + const outPath = path.join(specsDir, file); + if (check) { + if (await fs.readFile(outPath, 'utf8').catch(() => '') !== next) { + drifted = true; + console.error(`${file}: GitHub changed what fake-github serves`); + } + continue; + } + await fs.mkdir(specsDir, {recursive: true}); + await fs.writeFile(outPath, next); + console.log(`${file}: updated`); +} + +if (drifted) { + console.error('Run `npm run contract:update`, review the diff, and update fake-github to match.'); + process.exit(1); +} + +async function download(url, as) { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download ${url}: ${res.status}`); + } + return as === 'json' ? res.json() : res.text(); +} + +function trimRest(spec, {operations, webhooks}) { + const paths = {}; + for (const operation of operations) { + const [method, opPath] = operation.split(' '); + const op = spec.paths[opPath]?.[method.toLowerCase()]; + if (!op) { + throw new Error(`rest: ${operation} is not in the upstream description`); + } + paths[opPath] = {...paths[opPath], [method.toLowerCase()]: stripDocs({requestBody: op.requestBody, responses: op.responses})}; + } + + const hooks = {}; + for (const name of webhooks) { + const op = spec['x-webhooks'][name]?.post; + if (!op) { + throw new Error(`rest: webhook ${name} is not in the upstream description`); + } + hooks[name] = {post: stripDocs({requestBody: op.requestBody})}; + } + + const components = {}; + const queue = collectRefs([paths, hooks]); + while (queue.length) { + const [kind, name] = queue.pop().replace('#/components/', '').split('/'); + if (components[kind]?.[name]) { + continue; + } + components[kind] = {...components[kind], [name]: stripDocs(spec.components[kind][name])}; + queue.push(...collectRefs(components[kind][name])); + } + + return { + openapi: spec.openapi, + info: {title: spec.info.title}, + paths, + 'x-webhooks': hooks, + components: Object.fromEntries(Object.keys(components).sort().map((kind) => [kind, sortKeys(components[kind])])), + }; +} + +// GitHub edits prose and examples often; dropping them keeps --check focused on shape changes. +function stripDocs(node, isPropertyMap = false) { + if (Array.isArray(node)) { + return node.map((item) => stripDocs(item)); + } + if (!node || typeof node !== 'object') { + return node; + } + const out = {}; + for (const [key, value] of Object.entries(node)) { + if (!isPropertyMap && (['description', 'example', 'examples', 'summary', 'externalDocs', 'title'].includes(key) || key.startsWith('x-'))) { + continue; + } + out[key] = stripDocs(value, !isPropertyMap && key === 'properties'); + } + return out; +} + +function collectRefs(node, found = []) { + if (Array.isArray(node)) { + node.forEach((item) => collectRefs(item, found)); + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') { + found.push(value); + } else { + collectRefs(value, found); + } + } + } + return found; +} + +function sortKeys(object) { + return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b))); +} + +// Keeps the listed object/interface fields, every enum, scalar and input type they reference, and unions +// narrowed to listed members. Querying anything else then fails validation, like an uncovered REST route. +function trimGraphQL(sdl, fields) { + const definitions = new Map(parse(sdl).definitions.filter((d) => 'name' in d && d.name).map((d) => [d.name.value, d])); + const builtins = new Set(specifiedScalarTypes.map((t) => t.name)); + const kept = new Map(); + const queue = ['Query']; + + while (queue.length) { + const name = queue.pop(); + if (kept.has(name) || builtins.has(name)) { + continue; + } + const def = definitions.get(name); + if (!def) { + throw new Error(`graphql: ${name} is not in the upstream schema`); + } + + let trimmed = stripDirectives({...def, description: undefined}); + if (def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.INTERFACE_TYPE_DEFINITION) { + const wanted = fields[name]; + if (!wanted) { + throw new Error(`graphql: ${name} is reachable from a listed field; add it to operations.json`); + } + const fieldDefs = def.fields.filter((f) => wanted.includes(f.name.value)); + const missing = wanted.filter((f) => !fieldDefs.some((d) => d.name.value === f)); + if (missing.length) { + throw new Error(`graphql: ${name} has no field(s) ${missing.join(', ')}`); + } + trimmed = { + ...trimmed, + interfaces: (def.interfaces ?? []).filter((i) => fields[i.name.value]), + fields: fieldDefs.map((f) => stripDirectives({...f, description: undefined, arguments: f.arguments?.map((a) => stripDirectives({...a, description: undefined}))})), + }; + for (const f of trimmed.fields) { + queue.push(namedType(f.type), ...(f.arguments ?? []).map((a) => namedType(a.type))); + } + queue.push(...trimmed.interfaces.map((i) => i.name.value)); + } else if (def.kind === Kind.UNION_TYPE_DEFINITION) { + trimmed = {...trimmed, types: def.types.filter((t) => fields[t.name.value])}; + queue.push(...trimmed.types.map((t) => t.name.value)); + } else if (def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + trimmed = {...trimmed, fields: def.fields.map((f) => stripDirectives({...f, description: undefined}))}; + queue.push(...trimmed.fields.map((f) => namedType(f.type))); + } else if (def.kind === Kind.ENUM_TYPE_DEFINITION) { + trimmed = {...trimmed, values: def.values.map((v) => stripDirectives({...v, description: undefined}))}; + } + kept.set(name, trimmed); + } + + const schema = buildASTSchema({kind: Kind.DOCUMENT, definitions: [...kept.values()]}); + assertValidSchema(schema); + return `${printSchema(lexicographicSortSchema(schema))}\n`; +} + +function namedType(type) { + return type.kind === Kind.NAMED_TYPE ? type.name.value : namedType(type.type); +} + +function stripDirectives(node) { + return {...node, directives: (node.directives ?? []).filter((d) => d.name.value === 'deprecated')}; +} diff --git a/e2e/integration/fake-github/contract/validator.ts b/e2e/integration/fake-github/contract/validator.ts new file mode 100644 index 000000000..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..1e5c5ecf9 --- /dev/null +++ b/e2e/integration/real-github/journey.spec.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {validateGraphQLQuery, validateResponse, validateWebhook} from '../fake-github/contract/validator'; +import {signatures} from '../fake-github/webhooks'; +import {mattermostURL, webhookURL} from '../support/constants'; +import {readUserInfo} from '../support/kv'; +import {channelPosts, chooseOption, openChannel, openCreateIssueModal, runSlashCommand, selectField, sidebarButton, waitForPost} from '../support/ui'; + +import {gh, ghOK} from './api'; +import {canSignInWithPassword, realGitHub} from './env'; +import {expect, test, type RealGitHubFixture} from './fixture'; +import {readState} from './fixtures'; +import {completeGitHubSignIn} from './login/github_login'; +import {reserveWrite} from './rate_limit'; + +const {fullName} = realGitHub; + +type Delivery = {id: number; event: string; action: string | null; delivered_at: string}; +type DeliveryDetail = Delivery & {request: {headers: Record; payload: Record}}; + +// GitHub can't reach this Mattermost, so the hook's recorded delivery is fetched and posted to the plugin. +async function replayDelivery(real: RealGitHubFixture, event: string, match: (payload: Record) => boolean) { + const path = `/repos/${fullName}/hooks/${real.state.hookId}/deliveries`; + let detail: DeliveryDetail | undefined; + await expect.poll(async () => { + const recent = await ghOK('GET', `${path}?per_page=20`); + for (const d of recent.filter((r) => r.event === event)) { + const candidate = await ghOK('GET', `${path}/${d.id}`); + if (match(candidate.request.payload)) { + detail = candidate; + return true; + } + } + return false; + }, {message: `no ${event} delivery recorded`, timeout: 2 * 60_000, intervals: [5_000]}).toBe(true); + + const {payload, headers} = detail!.request; + + // The API hands back the parsed payload, not the bytes GitHub signed, so it is re-signed with the hook's secret. + const body = JSON.stringify(payload); + const res = await fetch(webhookURL, { + method: 'POST', + body, + headers: {'Content-Type': 'application/json', 'X-GitHub-Event': event, 'X-GitHub-Delivery': headers['X-GitHub-Delivery'] ?? '', ...signatures(real.state.webhookSecret, body)}, + }); + return {status: res.status, payload}; +} + +test.describe.serial('real GitHub with a KV-seeded machine token', () => { + test('GitHub still behaves the way fake-github models it', async () => { + const hooks = await gh('GET', `/repos/${fullName}/hooks`); + const accepted = (hooks.headers.get('x-accepted-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean); + expect.soft(accepted, 'fake-github answers repo hooks with 404 unless the token has one of these scopes').toEqual(expect.arrayContaining(['admin:repo_hook', 'read:repo_hook', 'repo', 'write:repo_hook'])); + expect.soft(accepted).not.toContain('public_repo'); + + const query = `{search(type: ISSUE, first: 1, query: "repo:${fullName} label:e2e-fixture") {nodes {... on Issue {state} ... on PullRequest {state}}}}`; + expect(validateGraphQLQuery(query).errors, 'the validator lets this shape conflict through').toEqual([]); + const graphql = await ghOK<{data?: unknown; errors?: unknown[]}>('POST', '/graphql', {body: {query}}); + expect.soft(graphql.errors, 'GitHub answers overlapping Issue/PullRequest state fields').toBeUndefined(); + + const token = await fetch(`${realGitHub.webURL}/login/oauth/access_token`, { + method: 'POST', + headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, + body: JSON.stringify({client_id: realGitHub.oauth.clientId, client_secret: realGitHub.oauth.clientSecret, code: 'not-a-real-code'}), + }); + expect.soft(token.status, 'token errors come back as HTTP 200').toBe(200); + expect.soft(await token.json()).toMatchObject({error: 'bad_verification_code'}); + + const {issue, pull} = readState(); + for (const path of ['/user', `/repos/${fullName}`, `/repos/${fullName}/issues/${issue.number}`, `/repos/${fullName}/pulls/${pull.number}`, `/repos/${fullName}/labels`]) { + expect.soft(validateResponse('GET', path, 200, await ghOK('GET', path)), `real ${path} matches the committed spec`).toEqual([]); + } + }); + + test('the sidebar and tooltip show the front-loaded issue and pull request', async ({pw, real}) => { + real.connectMachine(); + const {page, channelsPage} = await openChannel(pw, real.user, real.team.name); + + await runSlashCommand(channelsPage, '/github me'); + await expect(page.getByText(real.state.login).last()).toBeVisible(); + + const rhs = page.locator('#rhsContainer'); + await sidebarButton(page, 'fa-compress').click(); + await expect(rhs.getByRole('link', {name: real.state.pull.title})).toBeVisible(); + await sidebarButton(page, 'fa-list-ol').click(); + await expect(rhs.getByRole('link', {name: real.state.issue.title})).toBeVisible(); + + const url = `${realGitHub.webURL}/${fullName}/issues/${real.state.issue.number}`; + await real.userClient.createPost({channel_id: real.channel.id, message: url} as any); + await page.getByRole('link', {name: url}).last().hover(); + await expect(page.locator('.github-tooltip .box').first()).toContainText(real.state.issue.title); + }); + + test('an issue created from a post reaches GitHub, and its webhook posts back to the channel', async ({pw, real}) => { + real.connectMachine(); + const title = `E2E journey ${Date.now()}`; + await real.userClient.executeCommand(`/github subscriptions add ${fullName} --features issues`, {channel_id: real.channel.id, team_id: real.team.id}); + await real.userClient.createPost({channel_id: real.channel.id, message: 'File this from Mattermost'} as any); + const {channelsPage, page} = await openChannel(pw, real.user, real.team.name); + + const modal = await openCreateIssueModal(channelsPage, page); + await expect(selectField(modal, 'Organization')).toContainText(realGitHub.org); + await chooseOption(page, selectField(modal, 'Repository'), realGitHub.repo); + await modal.getByLabel('Title for the GitHub Issue').fill(title); + await reserveWrite(); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + + const reply = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); + const number = Number(/\/issues\/(\d+)/.exec(reply.message)?.[1]); + try { + expect(await ghOK('GET', `/repos/${fullName}/issues/${number}`)).toMatchObject({title, user: {login: real.state.login}}); + + const {status, payload} = await replayDelivery(real, 'issues', (p) => p.action === 'opened' && p.issue?.number === number); + expect.soft(validateWebhook('issues', payload), 'the real payload matches the schema fake-github validates against').toEqual([]); + expect(status).toBe(200); + const post = await waitForPost(() => channelPosts(real.adminClient, real.channel.id), (p) => p.message.includes('#new-issue') && p.message.includes(title), 'issues.opened post'); + expect(post.message).toContain(`/${fullName}/issues/${number}`); + } finally { + await ghOK('PATCH', `/repos/${fullName}/issues/${number}`, {body: {state: 'closed', state_reason: 'not_planned'}}); + } + }); +}); + +test.describe('real GitHub sign-in', () => { + test.skip(!canSignInWithPassword(), 'needs E2E_GITHUB_LOGIN_USERNAME, _PASSWORD and _TOTP_SECRET'); + + test('a user connects through github.com with password and TOTP', async ({pw, real}) => { + const {page} = await openChannel(pw, real.user, real.team.name); + const complete = `${mattermostURL}/plugins/github/oauth/complete`; + + await page.goto(`${mattermostURL}/plugins/github/oauth/connect`); + await completeGitHubSignIn(page, () => page.url().startsWith(complete)); + await expect(page.getByText(/Completed connecting to GitHub/i)).toBeVisible(); + + const info = readUserInfo(real.user.id); + expect(info?.GitHubUsername.toLowerCase()).toBe(realGitHub.login.username!.toLowerCase()); + const token = info!.Token.access_token; + try { + const me = await gh('GET', '/user', {token}); + const scopes = (me.headers.get('x-oauth-scopes') ?? '').split(',').map((s) => s.trim()).filter(Boolean).sort(); + expect(scopes, 'the plugin asks for these scopes without /github connect private').toEqual(['admin:org_hook', 'notifications', 'public_repo', 'read:org']); + + const hooks = await gh('GET', `/repos/${fullName}/hooks`, {token}); + expect.soft(hooks.status, 'fake-github hides repo hooks from public_repo tokens, so /github subscriptions add always notes a missing webhook').toBe(404); + } finally { + // Revoking the grant brings back the consent screen and drops any widened scopes before the next run. + const basic = `Basic ${Buffer.from(`${realGitHub.oauth.clientId}:${realGitHub.oauth.clientSecret}`).toString('base64')}`; + await gh('DELETE', `/applications/${realGitHub.oauth.clientId}/grant`, {auth: basic, body: {access_token: token}}); + } + }); +}); diff --git a/e2e/integration/real-github/login/github_login.ts b/e2e/integration/real-github/login/github_login.ts new file mode 100644 index 000000000..1a49cc2c2 --- /dev/null +++ b/e2e/integration/real-github/login/github_login.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Locator, Page} from '@playwright/test'; + +import {realGitHub} from '../env'; + +import {generateTotp} from './totp'; + +// Walks github.com sign-in, 2FA and OAuth consent until isDone. GitHub skips the emailed device check for 2FA accounts. +export async function completeGitHubSignIn(page: Page, isDone: () => boolean, timeoutMs = 2 * 60_000) { + const {username, password, totpSecret} = realGitHub.login; + let lastCode = ''; + + const steps: Array<[Locator, (el: Locator) => Promise]> = [ + [page.locator('input#login_field'), async (el) => { + await el.fill(username!); + await page.locator('input#password').fill(password!); + await page.locator('input[name="commit"]').click(); + }], + [page.locator('a[href="/sessions/two-factor/app"]'), (el) => el.click()], + [page.locator('input#app_totp'), async (el) => { + let code = generateTotp(totpSecret!); + while (code === lastCode) { + await page.waitForTimeout(2000); + code = generateTotp(totpSecret!); + } + lastCode = code; + + // The field submits itself once six digits are in. + await el.fill(code); + }], + [page.getByRole('button', {name: /^(remind me later|not now|skip)/i}).first(), (el) => el.click()], + [page.locator('button#js-oauth-authorize-btn:not([disabled])'), (el) => el.click()], + ]; + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (isDone()) { + return; + } + if (new URL(page.url()).pathname.startsWith('/sessions/verified-device')) { + throw new Error('GitHub asked for an emailed device code; the login account needs 2FA with an authenticator app'); + } + for (const [el, act] of steps) { + if (await el.isVisible().catch(() => false)) { + await act(el); + await page.waitForLoadState('domcontentloaded').catch(() => undefined); + break; + } + } + await page.waitForTimeout(1000); + } + throw new Error(`GitHub sign-in did not finish; last page was ${new URL(page.url()).pathname}`); +} diff --git a/e2e/integration/real-github/login/totp.ts b/e2e/integration/real-github/login/totp.ts new file mode 100644 index 000000000..9ef21d596 --- /dev/null +++ b/e2e/integration/real-github/login/totp.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createHmac} from 'node:crypto'; + +// RFC 6238 TOTP. +export function generateTotp(secret: string, stepSeconds = 30): string { + const key = base32Decode(secret); + const counter = Math.floor(Date.now() / 1000 / stepSeconds); + const buf = Buffer.alloc(8); + buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0); + buf.writeUInt32BE(counter >>> 0, 4); + const hmac = createHmac('sha1', key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (code % 1_000_000).toString().padStart(6, '0'); +} + +function base32Decode(secret: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = ''; + for (const char of secret.toUpperCase().replace(/[=\s-]/g, '')) { + const value = alphabet.indexOf(char); + if (value >= 0) { + bits += value.toString(2).padStart(5, '0'); + } + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(parseInt(bits.slice(i, i + 8), 2)); + } + return Buffer.from(bytes); +} diff --git a/e2e/integration/real-github/rate_limit.ts b/e2e/integration/real-github/rate_limit.ts new file mode 100644 index 000000000..39ebac2b9 --- /dev/null +++ b/e2e/integration/real-github/rate_limit.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'node:fs'; +import path from 'node:path'; + +import {realGitHub} from './env'; + +// GitHub's secondary limits for content-creating requests. Kept on disk so back-to-back local runs share one budget. +const limits = [ + {windowMs: 60_000, max: 80}, + {windowMs: 60 * 60_000, max: 500}, +]; + +function readLog(): number[] { + try { + return JSON.parse(fs.readFileSync(realGitHub.writeLogPath, 'utf8')); + } catch { + return []; + } +} + +// Waits until one more write fits in every window, then records it. Call before each write, including ones the plugin makes. +export async function reserveWrite() { + for (;;) { + const now = Date.now(); + const log = readLog().filter((t) => now - t < limits.at(-1)!.windowMs); + const waitMs = Math.max(0, ...limits.map(({windowMs, max}) => { + const inWindow = log.filter((t) => now - t < windowMs); + return inWindow.length < max ? 0 : inWindow[inWindow.length - max] + windowMs - now; + })); + if (waitMs === 0) { + fs.mkdirSync(path.dirname(realGitHub.writeLogPath), {recursive: true}); + fs.writeFileSync(realGitHub.writeLogPath, JSON.stringify([...log, now])); + return; + } + if (waitMs > 5 * 60_000) { + throw new Error(`GitHub write budget exhausted for ${Math.ceil(waitMs / 60_000)} more minutes`); + } + await new Promise((r) => setTimeout(r, waitMs)); + } +} diff --git a/e2e/integration/scripts/visual.sh b/e2e/integration/scripts/visual.sh new file mode 100755 index 000000000..2d61420e8 --- /dev/null +++ b/e2e/integration/scripts/visual.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +PW_VERSION="$(node -p "require('@playwright/test/package.json').version")" +export PW_VERSION +# Baselines are tied to this exact server build; bump it together with test:visual-update-snapshots. +export MM_IMAGE=mattermost/mattermost-enterprise-edition:12.0.1 + +# A separate, throwaway stack: the pinned server must not start on a database another image already migrated. +export COMPOSE_PROJECT_NAME=github-plugin-e2e-visual +export MM_PORT="${VISUAL_MM_PORT:-8077}" +export PW_BROWSER_PORT="${VISUAL_BROWSER_PORT:-3337}" +trap 'docker compose --profile visual down -v >/dev/null 2>&1' EXIT + +docker compose --profile visual up -d --wait + +PW_VISUAL_BROWSER_WS="ws://127.0.0.1:${PW_BROWSER_PORT}/" npx playwright test --grep @visual "$@" diff --git a/e2e/integration/support/admin.ts b/e2e/integration/support/admin.ts new file mode 100644 index 000000000..e750b5b01 --- /dev/null +++ b/e2e/integration/support/admin.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@mattermost/client'; +import {testConfig} from '@mattermost/playwright-lib'; + +export async function loginAdmin(): Promise { + const client = new Client4(); + client.setUrl(testConfig.baseURL); + await client.login(testConfig.adminUsername, testConfig.adminPassword); + return client; +} diff --git a/e2e/integration/support/constants.ts b/e2e/integration/support/constants.ts new file mode 100644 index 000000000..178850ac4 --- /dev/null +++ b/e2e/integration/support/constants.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// docker compose reads the same file, so MM_PORT/MM_IMAGE stay in sync between the stack and the tests. +const envFile = path.join(__dirname, '../.env'); +if (fs.existsSync(envFile)) { + process.loadEnvFile(envFile); +} + +export const pluginId = 'github'; +export const pluginDistDir = path.join(__dirname, '../../../dist'); + +// Must match the server's SiteURL in docker-compose.yml. +process.env.PW_BASE_URL ??= `http://127.0.0.1:${process.env.MM_PORT ?? 8069}`; +export const mattermostURL = process.env.PW_BASE_URL; + +// Baked into the plugin by `make e2e-dist`. fake-github resolves from the Mattermost container (extra_hosts) +// and from Chromium (--host-resolver-rules); the tests reach it on localhost. +export const fakeGitHubHost = 'fake-github'; +export const fakeGitHubPort = 8093; +export const fakeGitHubURL = `http://${fakeGitHubHost}:${fakeGitHubPort}`; +export const fakeGitHubControlURL = `http://localhost:${fakeGitHubPort}`; + +export const oauthClientId = 'Iv1.e2e0123456789abc'; +export const oauthClientSecret = 'e2e-github-oauth-client-secret-0000000000'; +export const webhookSecret = 'e2e-github-webhook-secret'; + +// AES-256 needs a 32-byte key. +export const encryptionKey = 'e2e-encryption-key-0123456789abc'; + +export const webhookURL = `${mattermostURL}/plugins/${pluginId}/webhook`; + +export const oauthApp = { + clientId: oauthClientId, + clientSecret: oauthClientSecret, + callbackURL: `${mattermostURL}/plugins/${pluginId}/oauth/complete`, + name: 'Mattermost E2E', +}; diff --git a/e2e/integration/support/fake_github.ts b/e2e/integration/support/fake_github.ts new file mode 100644 index 000000000..52a1d6735 --- /dev/null +++ b/e2e/integration/support/fake_github.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {JournalEntry} from '../fake-github/server'; +import type {Account, Hook, Item, Thread, Token} from '../fake-github/state'; +import type {Delivery, WebhookRequest} from '../fake-github/webhooks'; + +import {fakeGitHubControlURL} from './constants'; + +async function control(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${fakeGitHubControlURL}/__control${path}`, { + method, + headers: {'Content-Type': 'application/json'}, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`fake-github control ${method} ${path} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; +} + +type ItemFields = Partial> & Pick; + +export const fakeGitHub = { + reset: () => control('POST', '/reset'), + requests: () => control('GET', '/requests'), + calls: async () => (await fakeGitHub.requests()).map((r) => `${r.method} ${r.path}`), + contractViolations: () => control('GET', '/contract-violations'), + settle: () => control('POST', '/settle'), + createUser: (login: string, extra: {name?: string; email?: string} = {}) => control('POST', '/users', {login, ...extra}), + createOrg: (login: string, options: {members?: string[]; admins?: string[]; ssoEnforced?: boolean} = {}) => control('POST', '/orgs', {login, ...options}), + setSSO: (org: string, enforced: boolean) => control('POST', '/sso', {org, enforced}), + createTeam: (org: string, slug: string, members: string[]) => control('POST', '/teams', {org, slug, members}), + createRepo: (fullName: string, options: {private?: boolean; archived?: boolean; admins?: string[]; writers?: string[]; labels?: string[]; milestones?: string[]} = {}) => control<{fullName: string; id: number}>('POST', '/repos', {fullName, ...options}), + createItem: (repo: string, fields: ItemFields) => control('POST', '/items', {repo, ...fields}), + updateItem: (repo: string, number: number, patch: Partial) => control('PATCH', '/items', {repo, number, ...patch}), + items: () => control('GET', '/items'), + createThread: (login: string, repo: string, fields: Partial = {}) => control('POST', '/threads', {login, repo, ...fields}), + createHook: (owner: string, repo: string | undefined, url: string, secret: string, events = ['*']) => control('POST', '/hooks', {owner, repo, url, secret, events}), + hooks: () => control('GET', '/hooks'), + issueToken: (login: string, scopes?: string[]) => control('POST', '/tokens', {login, scopes}), + grant: (login: string, scopes: string[]) => control<{scopes: string[]}>('POST', '/grants', {login, scopes}), + revoke: (token: string) => control('POST', '/revoke', {token}), + signIn: (login: string | null) => control('POST', '/sign-in', {login}), + setOAuthApp: (patch: {codeTTLms?: number; clientSecret?: string}) => control('POST', '/oauth-app', patch), + fault: (method: string, path: string, status: number, options: {body?: unknown; headers?: Record; times?: number} = {}) => control('POST', '/fault', {method, path, status, ...options}), + webhook: (request: WebhookRequest & {deliver?: boolean}) => control<{payload: Record; deliveries: Delivery[]}>('POST', '/webhooks', request), +}; diff --git a/e2e/integration/support/fixture.ts b/e2e/integration/support/fixture.ts new file mode 100644 index 000000000..578e05dca --- /dev/null +++ b/e2e/integration/support/fixture.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Channel} from '@mattermost/types/channels'; +import type {Team} from '@mattermost/types/teams'; +import type {UserProfile} from '@mattermost/types/users'; +import {expect, test as base} from '@mattermost/playwright-lib'; + +import type {Token} from '../fake-github/state'; + +import {fakeGitHub} from './fake_github'; +import {seedUserInfo} from './kv'; + +export {expect}; + +type GitHubConnection = {login: string; token: Token}; + +export type GitHubFixture = { + adminClient: Client4; + user: UserProfile; + userClient: Client4; + team: Team; + channel: Channel; + login: string; + connect: (mattermostUser?: UserProfile, options?: {login?: string; scopes?: string[]; allowedPrivateRepos?: boolean}) => Promise; +}; + +export type FailureScreenshotOptions = {failureScreenshots: boolean}; + +// pw.initSetup() is deliberately avoided: it resets server config, which drops the plugin's settings and PluginStates. +export const test = base.extend<{github: GitHubFixture; failureScreenshot: void}, FailureScreenshotOptions>({ + failureScreenshots: [false, {option: true, scope: 'worker'}], + + // Playwright's own failure screenshot runs after pw closes the context and only sees a blank page. + failureScreenshot: [async ({pw, failureScreenshots}, use, testInfo) => { + await use(); + if (!failureScreenshots || testInfo.status === testInfo.expectedStatus) { + return; + } + for (const [i, page] of (pw.testBrowser.context?.pages() ?? []).entries()) { + const body = await page.screenshot({fullPage: true}).catch(() => undefined); + if (body) { + await testInfo.attach(`failure-page-${i}`, {body, contentType: 'image/png'}); + } + } + }, {auto: true}], + + github: async ({pw}, use) => { + await fakeGitHub.reset(); + + const {adminClient} = await pw.getAdminClient(); + const team = await pw.createNewTeam(adminClient, {name: 'github', displayName: 'GitHub', type: 'O', unique: true}); + const user = await pw.createNewUserProfile(adminClient, {prefix: 'github', disableTutorial: true, disableOnboarding: true}); + await adminClient.addToTeam(team.id, user.id); + const {client: userClient} = await pw.makeClient(user); + if (!userClient) { + throw new Error(`Failed to log in ${user.username}`); + } + const channel = await adminClient.getChannelByName(team.id, 'town-square'); + + // GitHub logins are global; deriving them from the random Mattermost username keeps tests independent. + const login = `gh-${user.username}`; + await fakeGitHub.createUser(login); + + const connect: GitHubFixture['connect'] = async (mattermostUser = user, options = {}) => { + const ghLogin = options.login ?? (mattermostUser.id === user.id ? login : `gh-${mattermostUser.username}`); + const token = await fakeGitHub.issueToken(ghLogin, options.scopes); + seedUserInfo(mattermostUser.id, ghLogin, token.value, {allowedPrivateRepos: options.allowedPrivateRepos}); + return {login: ghLogin, token}; + }; + + await use({adminClient, user, userClient, team, channel, login, connect}); + + await fakeGitHub.settle(); + expect(await fakeGitHub.contractViolations(), 'fake-github traffic must match GitHub\'s REST, GraphQL and webhook schemas').toEqual([]); + }, +}); diff --git a/e2e/integration/support/kv.ts b/e2e/integration/support/kv.ts new file mode 100644 index 000000000..f2a699cb3 --- /dev/null +++ b/e2e/integration/support/kv.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ + +import {execFileSync} from 'node:child_process'; +import {createCipheriv, createDecipheriv, randomBytes} from 'node:crypto'; +import path from 'node:path'; + +import {encryptionKey, pluginId} from './constants'; + +export type StoredUserInfo = { + UserID: string; + Token: {access_token: string; token_type: string; expiry: string}; + GitHubUsername: string; + LastToDoPostAt: number; + Settings: {sidebar_buttons: string; daily_reminder: boolean; daily_reminder_on_change: boolean; notifications: boolean}; + AllowedPrivateRepos: boolean; + MM34646ResetTokenDone: boolean; +}; + +const composeDir = path.join(__dirname, '..'); + +// Keys and format must match storeGitHubUserInfo and storeGitHubToUserIDMapping (server/plugin/plugin.go). +export function seedUserInfo(mattermostUserId: string, githubLogin: string, accessToken: string, options: {allowedPrivateRepos?: boolean; settings?: Partial; lastToDoPostAt?: number; key?: string} = {}) { + const info: StoredUserInfo = { + UserID: mattermostUserId, + + // Classic OAuth app tokens never expire; oauth2 stores the zero time. + Token: {access_token: encrypt(accessToken, options.key), token_type: 'bearer', expiry: '0001-01-01T00:00:00Z'}, + GitHubUsername: githubLogin, + + // A recent value keeps the daily reminder from posting into the DM channel tests assert on. + LastToDoPostAt: options.lastToDoPostAt ?? Date.now(), + Settings: {sidebar_buttons: 'team', daily_reminder: true, daily_reminder_on_change: false, notifications: true, ...options.settings}, + AllowedPrivateRepos: options.allowedPrivateRepos ?? false, + MM34646ResetTokenDone: true, + }; + upsert({ + [`${mattermostUserId}_githubtoken`]: JSON.stringify(info), + [`${githubLogin}_githubusername`]: mattermostUserId, + }); +} + +export function readUserInfo(mattermostUserId: string): StoredUserInfo | undefined { + const raw = get(`${mattermostUserId}_githubtoken`); + if (!raw) { + return undefined; + } + const info = JSON.parse(raw) as StoredUserInfo; + return {...info, Token: {...info.Token, access_token: decrypt(info.Token.access_token)}}; +} + +export function seedTokenResetDone() { + upsert({mm34646_token_reset_done: 'done'}); +} + +export function hasKey(key: string) { + return Boolean(get(key)); +} + +export function deleteKey(key: string) { + psql(`DELETE FROM pluginkeyvaluestore WHERE pluginid = '${pluginId}' AND pkey = ${quote(key)};`); +} + +function get(key: string) { + return psql(`SELECT convert_from(pvalue, 'UTF8') FROM pluginkeyvaluestore WHERE pluginid = '${pluginId}' AND pkey = ${quote(key)};`, ['-A', '-t']).trim(); +} + +function upsert(entries: Record) { + const values = Object.entries(entries).map(([key, value]) => `('${pluginId}', ${quote(key)}, convert_to(${quote(value)}, 'UTF8'), 0)`); + psql(`INSERT INTO pluginkeyvaluestore (pluginid, pkey, pvalue, expireat) VALUES ${values.join(', ')} + ON CONFLICT (pluginid, pkey) DO UPDATE SET pvalue = EXCLUDED.pvalue, expireat = 0;`); +} + +function psql(sql: string, flags: string[] = []) { + return execFileSync('docker', ['compose', 'exec', '-T', 'postgres', 'psql', '-U', 'mmuser', '-d', 'mattermost_test', '-v', 'ON_ERROR_STOP=1', '-q', ...flags], { + cwd: composeDir, + input: sql, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'inherit'], + env: process.env, + }); +} + +function quote(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +// Mirrors encrypt/decrypt in server/plugin/utils.go: PKCS#7 padding, AES-CFB with a random IV prefix, padded URL-safe base64. +export function encrypt(text: string, key = encryptionKey) { + const iv = randomBytes(16); + const plain = Buffer.from(text); + const padding = 16 - (plain.length % 16); + const cipher = createCipheriv('aes-256-cfb', Buffer.from(key), iv); + const body = Buffer.concat([cipher.update(Buffer.concat([plain, Buffer.alloc(padding, padding)])), cipher.final()]); + return Buffer.concat([iv, body]).toString('base64').replaceAll('+', '-').replaceAll('/', '_'); +} + +export function decrypt(text: string, key = encryptionKey) { + const data = Buffer.from(text.replaceAll('-', '+').replaceAll('_', '/'), 'base64'); + const decipher = createDecipheriv('aes-256-cfb', Buffer.from(key), data.subarray(0, 16)); + const plain = Buffer.concat([decipher.update(data.subarray(16)), decipher.final()]); + return plain.subarray(0, plain.length - plain[plain.length - 1]).toString(); +} diff --git a/e2e/integration/support/layout.ts b/e2e/integration/support/layout.ts new file mode 100644 index 000000000..d7f8caa6e --- /dev/null +++ b/e2e/integration/support/layout.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, type Locator} from '@playwright/test'; + +// Sub-pixel rounding differs between fonts and zoom levels. +const tolerance = 1; + +async function box(locator: Locator) { + const b = await locator.boundingBox(); + if (!b) { + throw new Error(`${locator} has no bounding box; is it visible?`); + } + return b; +} + +export async function expectInside(child: Locator, parent: Locator) { + const [c, p] = await Promise.all([box(child), box(parent)]); + expect({ + left: c.x >= p.x - tolerance, + top: c.y >= p.y - tolerance, + right: c.x + c.width <= p.x + p.width + tolerance, + bottom: c.y + c.height <= p.y + p.height + tolerance, + }, `${child} should sit inside ${parent}`).toEqual({left: true, top: true, right: true, bottom: true}); +} + +export async function expectNoOverlap(a: Locator, b: Locator) { + const [x, y] = await Promise.all([box(a), box(b)]); + const overlaps = x.x + tolerance < y.x + y.width && y.x + tolerance < x.x + x.width && + x.y + tolerance < y.y + y.height && y.y + tolerance < x.y + x.height; + expect(overlaps, `${a} and ${b} should not overlap`).toBe(false); +} + +export async function expectNothingClipped(root: Locator) { + const clipped = await root.evaluate((el, tol) => { + const offenders: string[] = []; + for (const node of [el, ...Array.from(el.querySelectorAll('*'))]) { + const style = getComputedStyle(node); + const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible' || style.textOverflow === 'ellipsis'; + if (clips && (node.scrollWidth > node.clientWidth + tol || node.scrollHeight > node.clientHeight + tol)) { + offenders.push(`<${node.tagName.toLowerCase()} class="${node.className}"> ${node.scrollWidth}x${node.scrollHeight} in ${node.clientWidth}x${node.clientHeight}`); + } + } + return offenders; + }, tolerance); + expect(clipped, `content under ${root} should not be clipped`).toEqual([]); +} diff --git a/e2e/integration/support/plugin.ts b/e2e/integration/support/plugin.ts new file mode 100644 index 000000000..6c8e2f73d --- /dev/null +++ b/e2e/integration/support/plugin.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type {Client4} from '@mattermost/client'; + +import {encryptionKey, oauthClientId, oauthClientSecret, pluginDistDir, pluginId, webhookSecret} from './constants'; +import {seedTokenResetDone} from './kv'; + +export const defaultPluginSettings = { + usepreregisteredapplication: false, + githuboauthclientid: oauthClientId, + githuboauthclientsecret: oauthClientSecret, + webhooksecret: webhookSecret, + encryptionkey: encryptionKey, + githuborg: '', + enterprisebaseurl: '', + enterpriseuploadurl: '', + enableleftsidebar: true, + enableprivaterepo: false, + connecttoprivatebydefault: false, + enablecodepreview: 'public', + enablewebhookeventlogging: false, + getnotificationfordraftprs: false, +}; + +export async function findPluginBundle(): Promise { + const files = await fs.readdir(pluginDistDir).catch(() => [] as string[]); + const bundles = await Promise.all( + files. + filter((name) => name.startsWith(`${pluginId}-`) && name.endsWith('.tar.gz')). + map(async (name) => { + const fullPath = path.join(pluginDistDir, name); + return {fullPath, mtime: (await fs.stat(fullPath)).mtimeMs}; + }), + ); + if (!bundles.length) { + throw new Error(`No ${pluginId} bundle found in ${pluginDistDir}; run \`make e2e-dist\` first`); + } + return bundles.sort((a, b) => b.mtime - a.mtime)[0].fullPath; +} + +export async function installPlugin(adminClient: Client4, settings: Partial = {}) { + const bundlePath = await findPluginBundle(); + const data = await fs.readFile(bundlePath); + const file = new File([data], path.basename(bundlePath), {type: 'application/gzip'}); + + await adminClient.uploadPlugin(file as unknown as globalThis.File, true); + await configureServer(adminClient, settings); + + // Otherwise the plugin resets every stored token through GitHub a minute after it starts. + seedTokenResetDone(); + await adminClient.enablePlugin(pluginId); + await waitForPluginRunning(adminClient); +} + +export async function patchPluginSettings(adminClient: Client4, settings: Partial) { + const config = await adminClient.getConfig(); + await adminClient.patchConfig({ + PluginSettings: { + Plugins: { + ...config.PluginSettings.Plugins, + [pluginId]: {...config.PluginSettings.Plugins[pluginId], ...settings}, + }, + }, + }); +} + +async function configureServer(adminClient: Client4, settings: Partial) { + await adminClient.patchConfig({ + ServiceSettings: {EnableTutorial: false, EnableOnboardingFlow: false}, + + // playwright-lib's random users use a short password that only passes these relaxed rules. + PasswordSettings: {MinimumLength: 5, Lowercase: false, Number: false, Uppercase: false, Symbol: false}, + }); + await patchPluginSettings(adminClient, {...defaultPluginSettings, ...settings}); +} + +export async function waitForPluginRunning(adminClient: Client4, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let lastState = 'unknown'; + while (Date.now() < deadline) { + const statuses = await adminClient.getPluginStatuses(); + const status = statuses.find((s) => s.plugin_id === pluginId); + if (status?.state === 2) { + return; + } + lastState = status ? `state=${status.state}` : 'not installed'; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Plugin ${pluginId} did not reach Running: ${lastState}`); +} diff --git a/e2e/integration/support/seed.ts b/e2e/integration/support/seed.ts new file mode 100644 index 000000000..a145ed5e2 --- /dev/null +++ b/e2e/integration/support/seed.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {GitHubFixture} from './fixture'; +import {fakeGitHub} from './fake_github'; + +export const repo = 'acme/widgets'; +export const labels = ['bug', 'enhancement', 'needs-design-review-from-the-platform-team']; +export const longTitle = 'Refactor the widget polishing pipeline so that it handles extremely long pull request titles gracefully '.repeat(2).trim(); + +// A fixed date keeps the tooltip's date line identical between runs. +const createdAt = '2024-01-15T12:00:00Z'; + +export async function seedWork(github: GitHubFixture) { + const dates = {createdAt, updatedAt: createdAt}; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [github.login, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: [github.login, 'octo-author'], labels, milestones: ['v1.0']}); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: longTitle, author: github.login, labels, body: 'Splits polishing into stages.', ...dates}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [github.login], ...dates}); + await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', assignees: [github.login], milestone: 1, ...dates}); + await fakeGitHub.createThread(github.login, repo, {number: pr.number, reason: 'mention'}); + await github.connect(); + return pr; +} diff --git a/e2e/integration/support/ui.ts b/e2e/integration/support/ui.ts new file mode 100644 index 000000000..12dfc96be --- /dev/null +++ b/e2e/integration/support/ui.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {Post} from '@mattermost/types/posts'; +import type {UserProfile} from '@mattermost/types/users'; +import {expect, type ChannelsPage, type PlaywrightExtended} from '@mattermost/playwright-lib'; +import type {Locator, Page} from '@playwright/test'; + +export async function openChannel(pw: PlaywrightExtended, user: UserProfile, teamName: string, channelName = 'town-square') { + const {channelsPage, page} = await pw.testBrowser.login(user); + await channelsPage.goto(teamName, channelName); + await channelsPage.toBeVisible(); + return {channelsPage, page}; +} + +export async function runSlashCommand(channelsPage: ChannelsPage, command: string) { + const postCreate = channelsPage.centerView.postCreate; + await postCreate.input.fill(command); + + // Enter would pick the highlighted slash-command autocomplete suggestion instead of sending. + await postCreate.sendMessageButton.click(); + await expect(postCreate.input).toHaveValue(''); +} + +export function sidebarButton(page: Page, icon: 'fa-compress' | 'fa-code-fork' | 'fa-list-ol' | 'fa-envelope') { + return page.locator(`a:has(> i.${icon})`).first(); +} + +export function sidebarButtonBar(page: Page) { + return sidebarButton(page, 'fa-compress').locator('xpath=ancestor::div[.//i[contains(@class, "fa-envelope")]][1]'); +} + +// Swaps per-run values (random usernames) for fixed text so screenshots are comparable. +export async function replaceText(target: Locator, replacements: Array<[string | RegExp, string]>) { + await target.evaluate((root, pairs) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + for (const [source, flags, to] of pairs) { + node.textContent = node.textContent!.replace(new RegExp(source, flags), to); + } + } + }, replacements.map(([from, to]) => (typeof from === 'string' ? [from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g', to] : [from.source, from.flags, to]))); +} + +export async function openCreateIssueModal(channelsPage: ChannelsPage, page: Page) { + const post = await channelsPage.getLastPost(); + await post.hover(); + await post.postMenu.actionsButton.click(); + await page.getByRole('menuitem', {name: 'Create GitHub Issue'}).click(); + const modal = page.getByRole('dialog').filter({hasText: 'Create GitHub Issue'}); + await expect(modal.getByLabel('Title for the GitHub Issue')).toBeVisible(); + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().endTime !== Infinity).map((a) => a.finished))); + return modal; +} + +// The react-select menus in the plugin don't expose role=option, so pick by filtering to one match. +export async function chooseOption(page: Page, field: Locator, option: string) { + await field.getByRole('combobox').click(); + await page.keyboard.type(option); + await expect(field.getByRole('log')).toContainText(/1 result available|option .* focused/); + await page.keyboard.press('Enter'); + await expect(field).toContainText(option); +} + +export function selectField(modal: Locator, label: string) { + return modal.getByText(label, {exact: true}).locator('xpath=..'); +} + +export function postWithText(page: Page, text: string | RegExp) { + return page.locator('.post').filter({hasText: text}).last(); +} + +export async function channelPosts(client: Client4, channelId: string): Promise { + const {order, posts} = await client.getPosts(channelId); + return order.map((id) => posts[id]); +} + +export async function botDMPosts(client: Client4, userId: string): Promise { + const bot = await client.getUserByUsername('github'); + const channel = await client.createDirectChannel([userId, bot.id]); + return channelPosts(client, channel.id); +} + +export async function waitForPost(load: () => Promise, match: (post: Post) => boolean, message: string): Promise { + let found: Post | undefined; + await expect.poll(async () => { + found = (await load()).find(match); + return Boolean(found); + }, {message, timeout: 15_000}).toBe(true); + return found!; +} diff --git a/e2e/integration/support/visual.css b/e2e/integration/support/visual.css new file mode 100644 index 000000000..cf371e8f3 --- /dev/null +++ b/e2e/integration/support/visual.css @@ -0,0 +1,4 @@ +/* Core UI that overlaps plugin posts and changes between runs. */ +.post .status { + visibility: hidden !important; +} diff --git a/e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png b/e2e/integration/tests/__screenshots__/visual.spec.ts/attach-issue-modal.png new file mode 100644 index 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..1322783a3 --- /dev/null +++ b/e2e/integration/tests/connect.spec.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; + +import {defaultScopes} from '../fake-github/server'; +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {hasKey, readUserInfo} from '../support/kv'; +import {botDMPosts, openChannel, postWithText, runSlashCommand, waitForPost} from '../support/ui'; + +const completePath = /\/plugins\/github\/oauth\/complete/; + +// `/github connect` answers with a link that opens the OAuth flow in a new tab, which closes itself on success. +async function startConnect(page: Page, channelsPage: Parameters[0]) { + await runSlashCommand(channelsPage, '/github connect'); + const complete = page.context().waitForEvent('response', {predicate: (r) => completePath.test(r.url()), timeout: 30_000}); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + postWithText(page, 'Click here to link your GitHub account').getByRole('link', {name: 'Click here to link your GitHub account'}).click(), + ]); + return {popup, complete}; +} + +test.describe('OAuth connect', () => { + test('connects through GitHub\'s consent page and stores the classic token', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await expect(popup.getByRole('heading', {name: 'Authorize Mattermost E2E'})).toBeVisible(); + await expect(popup.getByRole('listitem')).toHaveText(defaultScopes.slice().sort()); + + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + expect((await complete).status()).toBe(200); + + const stored = readUserInfo(github.user.id); + expect(stored).toMatchObject({GitHubUsername: github.login, AllowedPrivateRepos: false, MM34646ResetTokenDone: true, Token: {token_type: 'bearer'}}); + expect(stored!.Token.access_token).toMatch(/^gho_/); + + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_welcome', 'welcome DM'); + expect(await fakeGitHub.calls()).toEqual(expect.arrayContaining(['GET /login/oauth/authorize', 'POST /login/oauth/access_token', 'GET /api/user'])); + }); + + test('skips the consent page when the app was already authorized for these scopes', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.grant(github.login, defaultScopes); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {complete} = await startConnect(page, channelsPage); + + expect((await complete).status()).toBe(200); + expect(readUserInfo(github.user.id)?.GitHubUsername).toBe(github.login); + expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/authorize'); + }); + + // GitHub grants the union of every scope the user ever approved for the app, so an old `repo` grant comes back. + test('rejects a token that carries private repo scope from an earlier authorization', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.grant(github.login, ['repo']); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + const response = await complete; + + expect(response.status()).toBe(403); + expect(await response.text()).toContain('OAuth token scope does not match the requested permissions'); + expect(readUserInfo(github.user.id)).toBeUndefined(); + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => JSON.stringify(p.props).includes('OAuth token scope does not match'), 'scope mismatch DM'); + }); + + test('rejects a callback whose state was never issued', async ({pw, github}) => { + const {page} = await openChannel(pw, github.user, github.team.name); + + const response = await page.goto('/plugins/github/oauth/complete?code=0123456789abcdef0123&state=forgedstate0000'); + + expect(response?.status()).toBe(400); + expect(await response?.text()).toContain('invalid state token'); + expect(await fakeGitHub.calls()).not.toContain('POST /login/oauth/access_token'); + }); + + // GitHub reports token-exchange errors with HTTP 200 and an error field. + test('fails cleanly when GitHub rejects an expired authorization code', async ({pw, github}) => { + await fakeGitHub.signIn(github.login); + await fakeGitHub.setOAuthApp({codeTTLms: 0}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + const {popup, complete} = await startConnect(page, channelsPage); + await popup.getByRole('button', {name: 'Authorize Mattermost E2E'}).click(); + const response = await complete; + + expect(response.status()).toBe(500); + expect(await response.text()).toContain('failed to exchange oauth code into token'); + expect(readUserInfo(github.user.id)).toBeUndefined(); + const exchange = (await fakeGitHub.requests()).filter((r) => r.path === '/login/oauth/access_token'); + expect(exchange.length).toBeGreaterThan(0); + expect(exchange.every((r) => r.status === 200)).toBe(true); + }); + + test('/github disconnect removes the stored token', async ({pw, github}) => { + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github disconnect'); + + await expect(postWithText(page, 'Disconnected your GitHub account.')).toBeVisible(); + expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); + expect(hasKey(`${github.login}_githubusername`)).toBe(false); + }); +}); + +test.describe('auth failures', () => { + test('a revoked token disconnects the user and DMs them to reconnect', async ({pw, github}) => { + const {token} = await github.connect(); + await fakeGitHub.revoke(token.value); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github me'); + + await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => (p.type as string) === 'custom_git_revoked_token', 'revoked-token DM'); + expect(hasKey(`${github.user.id}_githubtoken`)).toBe(false); + expect((await fakeGitHub.requests()).some((r) => r.path.startsWith('/api/') && r.status === 401)).toBe(true); + }); + + test('SAML enforcement keeps the connection and DMs a re-authorize link once', async ({pw, github}) => { + await fakeGitHub.createOrg('acme-sso', {members: [github.login], ssoEnforced: true}); + await fakeGitHub.createRepo('acme-sso/widgets'); + await github.connect(); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); + await runSlashCommand(channelsPage, '/github subscriptions add acme-sso/widgets'); + + const dm = await waitForPost(() => botDMPosts(github.adminClient, github.user.id), (p) => p.message.includes('SAML SSO session expired'), 'SAML DM'); + expect(dm.message).toContain('https://github.com/orgs/acme-sso/sso?authorization_request='); + await expect.poll(async () => (await botDMPosts(github.adminClient, github.user.id)).filter((p) => p.message.includes('SAML SSO session expired')).length).toBe(1); + expect(readUserInfo(github.user.id)?.GitHubUsername).toBe(github.login); + }); +}); diff --git a/e2e/integration/tests/contract.spec.ts b/e2e/integration/tests/contract.spec.ts new file mode 100644 index 000000000..631cd95ca --- /dev/null +++ b/e2e/integration/tests/contract.spec.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@playwright/test'; + +import {skeleton, validateGraphQLQuery, validateResponse, validateWebhook, webhookSkeleton} from '../fake-github/contract/validator'; +import {type FakeGitHub, startFakeGitHub} from '../fake-github/server'; +import {State, normalizeScopes, parseSearch} from '../fake-github/state'; +import {buildPayload, signatures} from '../fake-github/webhooks'; + +function seededState() { + const state = new State(); + state.addUser('alice'); + state.addOrg('acme', [], ['alice']); + state.addItem(state.addRepo('acme/widgets'), {kind: 'issue', title: 'Broken widget', author: 'alice'}); + return state; +} + +// These check the fake against GitHub's published schemas without a Mattermost server. +const port = 8193; +const base = `http://localhost:${port}`; +const app = {clientId: 'Iv1.contract', clientSecret: 'contract-secret', callbackURL: 'http://localhost:1/plugins/github/oauth/complete', name: 'Contract'}; + +test.describe('contract', () => { + let fake: FakeGitHub; + let token: string; + + const control = async (path: string, body: unknown) => (await fetch(`${base}/__control${path}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})).json(); + const api = (path: string, init: RequestInit = {}) => fetch(`${base}/api${path}`, {...init, headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init.headers}}); + + test.beforeAll(async () => { + fake = await startFakeGitHub({port, publicURL: base, app}); + }); + test.afterAll(() => fake.close()); + + test.beforeEach(async () => { + await control('/reset', {}); + await control('/users', {login: 'alice'}); + await control('/users', {login: 'bob'}); + await control('/orgs', {login: 'acme', members: ['alice', 'bob'], admins: ['alice']}); + await control('/teams', {org: 'acme', slug: 'core', members: ['bob']}); + await control('/repos', {fullName: 'acme/widgets', milestones: ['v1']}); + await control('/items', {repo: 'acme/widgets', kind: 'pull', title: 'Add widget', author: 'alice', reviewers: ['bob'], teamReviewers: ['core'], labels: ['bug']}); + await control('/items', {repo: 'acme/widgets', kind: 'issue', title: 'Broken widget', author: 'bob', assignees: ['alice'], milestone: 1}); + await control('/threads', {login: 'alice', repo: 'acme/widgets', number: 1, reason: 'review_requested'}); + token = (await control('/tokens', {login: 'alice'})).value; + }); + + test.afterEach(async () => { + await fetch(`${base}/__control/settle`, {method: 'POST'}); + expect(await (await fetch(`${base}/__control/contract-violations`)).json()).toEqual([]); + }); + + test('every REST route the plugin uses answers with a schema-valid body', async () => { + for (const path of ['/user', '/users/bob', '/user/orgs', '/user/repos', '/users/alice/repos', '/orgs/acme', '/orgs/acme/repos', '/orgs/acme/hooks', + '/repos/acme/widgets', '/repos/acme/widgets/issues/2', '/repos/acme/widgets/labels', '/repos/acme/widgets/assignees', '/repos/acme/widgets/milestones', + '/repos/acme/widgets/pulls/1', '/repos/acme/widgets/pulls/1/reviews', '/search/issues?q=is%3Apr+review-requested%3Abob', '/notifications']) { + expect((await api(path)).status, path).toBe(200); + } + expect((await api('/orgs/acme/members/bob')).status).toBe(204); + expect((await api('/repos/acme/widgets/issues', {method: 'POST', body: JSON.stringify({title: 'New', labels: ['bug']})})).status).toBe(201); + expect((await api('/repos/acme/widgets/issues/1/comments', {method: 'POST', body: JSON.stringify({body: 'hi'})})).status).toBe(201); + }); + + test('answers like GitHub for bad credentials, missing scopes and SAML', async () => { + const bad = await fetch(`${base}/api/user`, {headers: {Authorization: 'Bearer gho_nope'}}); + expect(bad.status).toBe(401); + expect(await bad.json()).toMatchObject({message: 'Bad credentials'}); + await control('/revoke', {token}); + expect((await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(type: ISSUE, query: "x", first: 1) { issueCount } }'})})).status).toBe(401); + token = (await control('/tokens', {login: 'alice'})).value; + + expect((await api('/repos/acme/widgets/hooks')).status).toBe(404); + await control('/users', {login: 'dave'}); + const narrow = (await control('/tokens', {login: 'dave', scopes: ['public_repo']})).value; + expect((await fetch(`${base}/api/user/orgs`, {headers: {Authorization: `Bearer ${narrow}`}})).status).toBe(403); + expect((await api('/user')).headers.get('x-oauth-scopes')).toBe('admin:org_hook, notifications, public_repo, read:org'); + + await control('/sso', {org: 'acme', enforced: true}); + const saml = await api('/repos/acme/widgets'); + expect(saml.status).toBe(403); + expect(saml.headers.get('x-github-sso')).toMatch(/^required; url=https:\/\/github\.com\/orgs\/acme\/sso\?authorization_request=/); + const search = await api('/search/issues?q=author%3Aalice'); + expect((await search.json()).total_count).toBe(0); + expect(search.headers.get('x-github-sso')).toMatch(/^partial-results; organizations=\d+$/); + + await control('/users', {login: 'carol'}); + const outsider = (await control('/tokens', {login: 'carol'})).value; + expect((await fetch(`${base}/api/repos/acme/widgets`, {headers: {Authorization: `Bearer ${outsider}`}})).status).toBe(200); + }); + + test('commenting through the API delivers issue_comment to the repository hook', async () => { + await control('/hooks', {owner: 'acme', repo: 'widgets', url: `${base}/`, secret: 's'}); + expect((await api('/repos/acme/widgets/issues/2/comments', {method: 'POST', body: JSON.stringify({body: 'on it'})})).status).toBe(201); + await fetch(`${base}/__control/settle`, {method: 'POST'}); + const delivered = (await (await fetch(`${base}/__control/requests`)).json()).filter((r: {method: string; path: string; body?: {comment?: {body: string}}}) => r.method === 'POST' && r.path === '/'); + expect(delivered.map((r: {body?: {comment?: {body: string}}}) => r.body?.comment?.body)).toEqual(['on it']); + }); + + test('paginates with Link headers', async () => { + for (let i = 0; i < 4; i++) { + await control('/items', {repo: 'acme/widgets', kind: 'issue', title: `Bulk ${i}`, author: 'alice'}); + } + const first = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2'); + expect(first.headers.get('link')).toContain('rel="next"'); + const last = await api('/search/issues?q=repo%3Aacme%2Fwidgets&per_page=2&page=3'); + expect(last.headers.get('link')).not.toContain('rel="next"'); + expect((await last.json()).items).toHaveLength(2); + }); + + test('the plugin\'s sidebar and digest queries validate and execute against GitHub\'s schema', async () => { + const query = `query($q: String!, $c: String) { pullRequest: search(first: 100, after: $c, query: $q, type: ISSUE) { issueCount pageInfo { hasNextPage endCursor } + nodes { ... on PullRequest { body number authorAssociation createdAt updatedAt repository { name nameWithOwner url owner { login } } state title author { login } url + labels(first: 100) { nodes { name color } } milestone { title } additions deletions changedFiles + reviewRequests(first: 100) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug organization { login } } } } } } + ... on Issue { number title state url milestone { title } } } } }`; + expect(validateGraphQLQuery(query).errors).toEqual([]); + + const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query, variables: {q: 'review-requested:bob is:pr is:OPEN archived:false org:acme'}})}); + const {data} = await res.json(); + expect(data.pullRequest.issueCount).toBe(1); + expect(data.pullRequest.nodes[0].reviewRequests.nodes.map((n: any) => n.requestedReviewer)).toEqual([ + {__typename: 'User', login: 'bob'}, + {__typename: 'Team', slug: 'core', organization: {login: 'acme'}}, + ]); + }); + + test('GraphQL rejects unknown fields and missing pagination bounds like GitHub', async () => { + expect(validateGraphQLQuery('{ search(first: 1, query: "x", type: ISSUE) { nodes { ... on Issue { nope } } } }').errors).toHaveLength(1); + const res = await api('/graphql', {method: 'POST', body: JSON.stringify({query: '{ search(query: "x", type: ISSUE) { issueCount } }'})}); + expect(res.status).toBe(200); + expect((await res.json()).errors[0].message).toContain('You must provide a `first` or `last` value'); + }); + + test('OAuth token exchange errors come back as HTTP 200, as form or JSON by Accept header', async () => { + const form = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', body: new URLSearchParams({client_id: app.clientId, client_secret: app.clientSecret, code: 'nope'})}); + expect(form.status).toBe(200); + expect(Object.fromEntries(new URLSearchParams(await form.text()))).toMatchObject({error: 'bad_verification_code'}); + + const json = await fetch(`${base}/login/oauth/access_token`, {method: 'POST', headers: {Accept: 'application/json'}, body: new URLSearchParams({client_id: app.clientId, client_secret: 'wrong', code: 'nope'})}); + expect(await json.json()).toMatchObject({error: 'incorrect_client_credentials'}); + }); + + test('webhook payloads match GitHub\'s event schemas and carry both signatures', async () => { + const state = fake.state; + for (const request of [ + {event: 'pull_request', action: 'opened', number: 1}, + {event: 'pull_request', action: 'review_requested', number: 1, requestedReviewer: 'bob'}, + {event: 'issues', action: 'opened', number: 2}, + {event: 'issue_comment', action: 'created', number: 2}, + {event: 'push'}, + ]) { + const payload = buildPayload(state, {repo: 'acme/widgets', sender: 'alice', ...request}); + expect(validateWebhook(request.event, payload), `${request.event}.${request.action}`).toEqual([]); + } + expect(signatures('secret', '{}')).toEqual({ + 'X-Hub-Signature': expect.stringMatching(/^sha1=[0-9a-f]{40}$/), + 'X-Hub-Signature-256': expect.stringMatching(/^sha256=[0-9a-f]{64}$/), + }); + }); + + test('creating a hook sends GitHub\'s ping delivery', async () => { + const received: string[] = []; + const {createServer} = await import('node:http'); + const sink = createServer((req, res) => { + received.push(String(req.headers['x-github-event'])); + res.end(); + }); + await new Promise((resolve) => sink.listen(8194, resolve)); + try { + const res = await api('/orgs/acme/hooks', {method: 'POST', body: JSON.stringify({name: 'web', config: {url: 'http://localhost:8194/hook', content_type: 'json', secret: 's'}, events: ['*']})}); + expect(res.status).toBe(201); + await expect.poll(() => received).toEqual(['ping']); + } finally { + sink.close(); + } + }); +}); + +test.describe('fake semantics', () => { + test('search qualifiers are case-insensitive, repeated org: is OR, and @me resolves to the viewer', () => { + expect(parseSearch('is:pr is:OPEN org:a org:b author:@me "two words"', 'alice')).toEqual([ + {qualifier: 'is', values: ['pr'], negated: false}, + {qualifier: 'is', values: ['OPEN'], negated: false}, + {qualifier: 'org', values: ['a', 'b'], negated: false}, + {qualifier: 'author', values: ['alice'], negated: false}, + {qualifier: 'text', values: ['two words'], negated: false}, + ]); + }); + + test('scopes covered by a broader grant are folded away', () => { + expect(normalizeScopes(['public_repo', 'repo', 'read:org', 'notifications'])).toEqual(['notifications', 'read:org', 'repo']); + expect(normalizeScopes(['read:org', 'admin:org'])).toEqual(['admin:org']); + }); + + test('skeletons satisfy their own schemas', () => { + expect(validateResponse('GET', '/user', 200, skeleton('private-user'))).toEqual([]); + expect(validateWebhook('ping', webhookSkeleton('ping'))).toEqual([]); + }); + + test('the fake rejects payloads GitHub would never send', () => { + expect(validateResponse('GET', '/user', 200, {login: 'x'})).not.toEqual([]); + const payload = buildPayload(seededState(), {event: 'issues', action: 'opened', repo: 'acme/widgets', number: 1, sender: 'alice'}); + expect(validateWebhook('issues', {...payload, action: 'teleported'})).not.toEqual([]); + }); +}); diff --git a/e2e/integration/tests/issues.spec.ts b/e2e/integration/tests/issues.spec.ts new file mode 100644 index 000000000..674806d3e --- /dev/null +++ b/e2e/integration/tests/issues.spec.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {channelPosts, chooseOption, openChannel, openCreateIssueModal, postWithText, selectField, waitForPost} from '../support/ui'; + +const repo = 'acme/widgets'; + +async function seedRepo(github: GitHubFixture, options: {canPush?: boolean} = {}) { + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [github.login, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: options.canPush === false ? ['octo-author'] : [github.login, 'octo-author'], labels: ['bug', 'enhancement'], milestones: ['v1.0']}); +} + +test.describe('link tooltip', () => { + test('shows pull request details for a github.com link', async ({pw, github}) => { + await seedRepo(github); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: 'octo-author', labels: ['enhancement'], head: 'polish', base: 'main'}); + await github.connect(); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: `Have a look: ${url}`} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await postWithText(page, 'Have a look').getByRole('link', {name: url}).hover(); + + const tooltip = page.locator('.github-tooltip').first(); + await expect(tooltip).toContainText('Add widget polishing'); + await expect(tooltip).toContainText(`#${pr.number}`); + await expect(tooltip.locator('.opened-by')).toContainText('octo-author'); + await expect(tooltip.locator('.commit-ref')).toHaveText(['main', 'polish']); + await expect(tooltip.locator('.labels')).toContainText('enhancement'); + expect(await fakeGitHub.calls()).toContain(`GET /api/repos/${repo}/pulls/${pr.number}`); + }); + + test('shows issue details for a github.com link', async ({pw, github}) => { + await seedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', body: 'Every Monday, without fail.'}); + await github.connect(); + const url = `https://github.com/${repo}/issues/${issue.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await page.getByRole('link', {name: url}).last().hover(); + + const tooltip = page.locator('.github-tooltip').first(); + await expect(tooltip).toContainText('Widgets squeak on Mondays'); + await expect(tooltip).toContainText('Every Monday, without fail.'); + }); +}); + +test.describe('create issue from a post', () => { + async function openCreateIssue(pw: Parameters[0], github: GitHubFixture, message: string) { + await github.userClient.createPost({channel_id: github.channel.id, message} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + return {page, modal: await openCreateIssueModal(channelsPage, page)}; + } + + test('creates the issue with labels, assignee and milestone and links back to the post', async ({pw, github}) => { + await seedRepo(github); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'The widget squeaks when it rains'); + + await expect(selectField(modal, 'Organization')).toContainText(github.login); + await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); + await modal.getByLabel('Title for the GitHub Issue').fill('Widget squeaks in the rain'); + await chooseOption(page, selectField(modal, 'Labels'), 'bug'); + await chooseOption(page, selectField(modal, 'Assignees'), github.login); + await chooseOption(page, selectField(modal, 'Milestone'), 'v1.0'); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + + const created = (await fakeGitHub.items()).find((i) => i.title === 'Widget squeaks in the rain'); + expect(created).toMatchObject({kind: 'issue', author: github.login, labels: ['bug'], assignees: [github.login], milestone: 1}); + expect(created!.body).toContain('The widget squeaks when it rains'); + const reply = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.startsWith('Created GitHub issue'), 'issue created reply'); + expect(reply.message).toContain(`https://github.com/${repo}/issues/${created!.number}`); + }); + + // GitHub silently drops labels, assignees and milestones from users without push access; the modal hides them. + test('hides issue attributes for a repository the user cannot push to', async ({pw, github}) => { + await seedRepo(github, {canPush: false}); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'Read-only repo message'); + + await chooseOption(page, selectField(modal, 'Repository'), 'widgets'); + + await expect(modal.getByText('Labels', {exact: true})).toHaveCount(0); + await expect(modal.getByText('Assignees', {exact: true})).toHaveCount(0); + await modal.getByLabel('Title for the GitHub Issue').fill('Filed without push access'); + await modal.getByRole('button', {name: 'Submit'}).click(); + await expect(modal).toBeHidden(); + expect((await fakeGitHub.items()).find((i) => i.title === 'Filed without push access')).toMatchObject({labels: [], assignees: []}); + }); + + test('switching organization lists that organization\'s repositories', async ({pw, github}) => { + await seedRepo(github); + await fakeGitHub.createOrg('gizmo-co', {members: [github.login]}); + await fakeGitHub.createRepo('gizmo-co/gizmos', {writers: [github.login]}); + await github.connect(); + const {page, modal} = await openCreateIssue(pw, github, 'Which org?'); + + await chooseOption(page, selectField(modal, 'Organization'), 'gizmo-co'); + + await expect.poll(() => fakeGitHub.calls(), {timeout: 5_000}).toContain('GET /api/orgs/gizmo-co/repos'); + await chooseOption(page, selectField(modal, 'Repository'), 'gizmos'); + }); +}); diff --git a/e2e/integration/tests/sidebar.spec.ts b/e2e/integration/tests/sidebar.spec.ts new file mode 100644 index 000000000..5d053aedc --- /dev/null +++ b/e2e/integration/tests/sidebar.spec.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, sidebarButton} from '../support/ui'; + +const repo = 'acme/widgets'; + +async function seedWork(github: GitHubFixture) { + const me = github.login; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createOrg('acme', {members: [me, 'octo-author']}); + await fakeGitHub.createRepo(repo, {writers: [me, 'octo-author'], milestones: ['v1.0']}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: me, labels: ['enhancement'], additions: 42, deletions: 7, changedFiles: 3}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Fix widget wobble', author: me, ciState: 'failure'}); + const review = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [me]}); + await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Closed widget work', author: me, state: 'closed'}); + await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak on Mondays', author: 'octo-author', assignees: [me], milestone: 1}); + await fakeGitHub.createThread(me, repo, {number: review.number, reason: 'review_requested'}); +} + +test('sidebar counts and RHS lists come from GitHub search and notifications', async ({pw, github}) => { + await seedWork(github); + await github.connect(); + const {page} = await openChannel(pw, github.user, github.team.name); + + await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); + await expect(sidebarButton(page, 'fa-code-fork')).toHaveText('1'); + await expect(sidebarButton(page, 'fa-list-ol')).toHaveText('1'); + await expect(sidebarButton(page, 'fa-envelope')).toHaveText('1'); + + const rhs = page.locator('#rhsContainer'); + await sidebarButton(page, 'fa-compress').click(); + await expect(rhs.getByRole('link', {name: 'Your Open Pull Requests'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Add widget polishing'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Fix widget wobble'})).toBeVisible(); + await expect(rhs.getByText('Closed widget work')).toHaveCount(0); + await expect(rhs.getByText('3 Files Changed')).toBeVisible(); + await expect(rhs.getByLabel('Success')).toHaveCount(1); + + await sidebarButton(page, 'fa-code-fork').click(); + await expect(rhs.getByRole('link', {name: 'Pull Requests Needing Review'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Please review the gizmo'})).toBeVisible(); + + await sidebarButton(page, 'fa-list-ol').click(); + await expect(rhs.getByRole('link', {name: 'Your Assignments'})).toBeVisible(); + await expect(rhs.getByRole('link', {name: 'Widgets squeak on Mondays'})).toBeVisible(); + await expect(rhs.getByRole('note', {name: 'v1.0'})).toBeVisible(); + + await sidebarButton(page, 'fa-envelope').click(); + await expect(rhs.getByRole('link', {name: 'Unread Messages'})).toBeVisible(); + await expect(rhs.getByText('Please review the gizmo')).toBeVisible(); + + const calls = await fakeGitHub.calls(); + expect(calls).toEqual(expect.arrayContaining(['POST /api/graphql', 'GET /api/notifications', `GET /api/repos/${repo}/pulls/1/reviews`, `GET /api/repos/${repo}/pulls/1`])); +}); + +test('an org lock limits the sidebar to that organization', async ({pw, github}) => { + await seedWork(github); + await fakeGitHub.createOrg('elsewhere', {members: [github.login]}); + await fakeGitHub.createRepo('elsewhere/gadgets', {writers: [github.login]}); + await fakeGitHub.createItem('elsewhere/gadgets', {kind: 'pull', title: 'Gadget PR outside the org', author: github.login}); + await github.connect(); + const {page} = await openChannel(pw, github.user, github.team.name); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('3'); + + const {patchPluginSettings} = await import('../support/plugin'); + await patchPluginSettings(github.adminClient, {githuborg: 'acme'}); + try { + await page.reload(); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('2'); + const graphql = (await fakeGitHub.requests()).filter((r) => r.path === '/api/graphql').at(-1); + expect(JSON.stringify(graphql?.body)).toContain('org:acme'); + } finally { + await patchPluginSettings(github.adminClient, {githuborg: ''}); + } +}); diff --git a/e2e/integration/tests/smoke.spec.ts b/e2e/integration/tests/smoke.spec.ts new file mode 100644 index 000000000..12e99b4f7 --- /dev/null +++ b/e2e/integration/tests/smoke.spec.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, postWithText, runSlashCommand} from '../support/ui'; + +test('/github me reports the connected GitHub account', async ({pw, github}) => { + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github me'); + + await expect(postWithText(page, 'You are connected to GitHub as')).toContainText(github.login); + expect(await fakeGitHub.calls()).toContain('GET /api/user'); +}); diff --git a/e2e/integration/tests/todo.spec.ts b/e2e/integration/tests/todo.spec.ts new file mode 100644 index 000000000..852a7497f --- /dev/null +++ b/e2e/integration/tests/todo.spec.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {openChannel, postWithText, runSlashCommand} from '../support/ui'; + +test('/github todo lists unread notifications, review requests, open PRs and assignments', async ({pw, github}) => { + const me = github.login; + await fakeGitHub.createUser('octo-author'); + await fakeGitHub.createRepo(`${me}/widgets`, {writers: ['octo-author']}); + const review = await fakeGitHub.createItem(`${me}/widgets`, {kind: 'pull', title: 'Please review the gizmo', author: 'octo-author', reviewers: [me]}); + await fakeGitHub.createItem(`${me}/widgets`, {kind: 'pull', title: 'My own widget PR', author: me}); + await fakeGitHub.createItem(`${me}/widgets`, {kind: 'issue', title: 'Assigned widget bug', author: 'octo-author', assignees: [me]}); + await fakeGitHub.createThread(me, `${me}/widgets`, {number: review.number, reason: 'review_requested'}); + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, '/github todo'); + + const todo = postWithText(page, 'Unread Messages'); + await expect(todo).toContainText('Review Requests'); + await expect(todo).toContainText('Please review the gizmo'); + await expect(todo).toContainText('Your Open Pull Requests'); + await expect(todo).toContainText('My own widget PR'); + await expect(todo).toContainText('Your Assignments'); + await expect(todo).toContainText('Assigned widget bug'); + + const searches = (await fakeGitHub.requests()).filter((r) => r.path === '/api/search/issues').map((r) => r.query.q); + expect(searches).toEqual(expect.arrayContaining([ + expect.stringContaining(`review-requested:${me}`), + expect.stringContaining(`author:${me}`), + expect.stringContaining(`assignee:${me}`), + ])); +}); diff --git a/e2e/integration/tests/ui_layout.spec.ts b/e2e/integration/tests/ui_layout.spec.ts new file mode 100644 index 000000000..00d549c5a --- /dev/null +++ b/e2e/integration/tests/ui_layout.spec.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; + +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {expectInside, expectNoOverlap, expectNothingClipped} from '../support/layout'; +import {labels, longTitle, repo, seedWork as seed} from '../support/seed'; +import {openChannel, openCreateIssueModal, postWithText, sidebarButton, sidebarButtonBar} from '../support/ui'; + +function sidebarButtons(page: Page) { + return (['fa-compress', 'fa-code-fork', 'fa-list-ol', 'fa-envelope'] as const).map((icon) => sidebarButton(page, icon)); +} + +test('the sidebar buttons fit the channel sidebar header without overlapping', async ({pw, github}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + const buttons = sidebarButtons(page); + await expect(buttons[0]).toHaveText('1'); + const sidebar = page.locator('#SidebarContainer'); + + for (const button of buttons) { + await expectInside(button, sidebar); + } + for (let i = 1; i < buttons.length; i++) { + await expectNoOverlap(buttons[i - 1], buttons[i]); + } +}); + +test('a long pull request title and many labels wrap inside the right-hand panel', async ({pw, github}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, 'fa-compress').click(); + const rhs = page.locator('#rhsContainer'); + const title = rhs.getByRole('link', {name: longTitle}); + + await expect(title).toBeVisible(); + await expectInside(title, rhs); + for (const label of labels) { + await expectInside(rhs.getByLabel(label), rhs); + } + await expectNothingClipped(rhs.locator('.scrollbar--view')); +}); + +test('the link tooltip stays inside the window and keeps its content', async ({pw, github}) => { + const pr = await seed(github); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText(longTitle.slice(0, 40)); + + const viewport = page.viewportSize()!; + const box = (await tooltip.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + await expectInside(tooltip.locator('.labels'), tooltip); +}); + +test('a webhook post with labels fits the post', async ({github, pw}) => { + const pr = await seed(github); + await fakeGitHub.createHook('acme', 'widgets', webhookURL, webhookSecret); + await github.userClient.executeCommand(`/github subscriptions add ${repo}`, {channel_id: github.channel.id, team_id: github.team.id}); + await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); + const {page} = await openChannel(pw, github.user, github.team.name); + + const post = postWithText(page, '#new-pull-request'); + await expect(post).toBeVisible(); + await expectInside(post.getByRole('heading', {name: longTitle}), post); + await expectNothingClipped(post.locator('.post-message__text')); +}); + +test.describe('accessibility', () => { + test('the sidebar buttons and right-hand panel have no violations', async ({pw, github, axe}) => { + await seed(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, 'fa-compress').click(); + await expect(page.locator('#rhsContainer').getByRole('link', {name: longTitle})).toBeVisible(); + await sidebarButtonBar(page).evaluate((el) => el.setAttribute('data-e2e-axe', 'github-sidebar-buttons')); + + const results = await axe.builder(page).include('[data-e2e-axe="github-sidebar-buttons"]').include('#rhsContainer .scrollbar--view').analyze(); + + // Known plugin bug: the icon-only GitHub profile and refresh links have no accessible name. + // Drop this entry once they are labelled; any other violation still fails. + expect(results.violations.flatMap((v) => v.nodes.map((n) => `${v.id}: ${n.html.match(/fa-[\w-]+/)?.[0] ?? n.target}`))).toEqual(['link-name: fa-github', 'link-name: fa-refresh']); + }); + + test('the create issue modal has no violations', async ({pw, github, axe}) => { + await seed(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + await openCreateIssueModal(channelsPage, page); + + const results = await axe.builder(page).include('[role="dialog"]').analyze(); + + // Known plugin bug: the five react-selects and the description textarea have no associated label. + // Drop this entry once they are labelled; any other violation still fails. + const knownUnlabelled = 'label: Form elements must have labels (6)'; + expect(results.violations.map((v) => `${v.id}: ${v.help} (${v.nodes.length})`)).toEqual([knownUnlabelled]); + }); +}); \ No newline at end of file diff --git a/e2e/integration/tests/visual.spec.ts b/e2e/integration/tests/visual.spec.ts new file mode 100644 index 000000000..46f53b81c --- /dev/null +++ b/e2e/integration/tests/visual.spec.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PlaywrightExtended} from '@mattermost/playwright-lib'; +import type {Locator} from '@playwright/test'; + +import type {WebhookRequest} from '../fake-github/webhooks'; +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {longTitle, repo, seedWork} from '../support/seed'; +import {channelPosts, openChannel, openCreateIssueModal, postWithText, replaceText, runSlashCommand, sidebarButton, sidebarButtonBar, waitForPost} from '../support/ui'; + +test.describe('@visual', () => { + // eslint-disable-next-line no-process-env + test.skip(!process.env.PW_VISUAL_BROWSER_WS, 'baselines only match the dockerized browser (npm run test:visual)'); + + // Core UI overlapping the target (avatars, status, timestamps) changes between runs. + async function settle(pw: PlaywrightExtended, target: Locator, {keepHover = false} = {}) { + const page = target.page(); + await expect(page.locator('.LoadingScreen')).toBeHidden(); + await pw.hideDynamicChannelsContent(page); + if (!keepHover) { + await page.mouse.move(0, 0); + } + await page.evaluate(() => document.fonts.ready); + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().endTime !== Infinity).map((a) => a.finished))); + await target.evaluate((el: HTMLElement) => { + const {x, y} = el.getBoundingClientRect(); + el.style.transform = `translate(${Math.round(x) - x}px, ${Math.round(y) - y}px)`; + }); + } + + // Users and commit SHAs are random per run, and "Opened 2 years ago" grows with the calendar. + async function freeze(github: GitHubFixture, target: Locator) { + await replaceText(target, [ + [github.login, 'gh-octocat'], + [`@${github.user.username}`, '@octocat'], + [github.user.username, 'octocat'], + [/\b[0-9a-f]{6,40}\b/g, 'a1b2c3'], + [/\b(a|an|a few|\d+) (seconds?|minutes?|hours?|days?|months?|years?)\b/g, '2 years'], + ]); + } + + async function shoot(pw: PlaywrightExtended, github: GitHubFixture, target: Locator, name: string, options?: {keepHover?: boolean}) { + await expect(target).toBeVisible(); + await freeze(github, target); + await settle(pw, target, options); + await expect(target).toHaveScreenshot(name); + } + + async function rhsTab(pw: PlaywrightExtended, github: GitHubFixture, icon: Parameters[1], title: string) { + await seedWork(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await sidebarButton(page, icon).click(); + const list = page.locator('#rhsContainer .scrollbar--view'); + await expect(list.getByRole('link', {name: title})).toBeVisible(); + return list; + } + + async function webhookPost(pw: PlaywrightExtended, github: GitHubFixture, request: Omit, marker: string) { + await fakeGitHub.createHook('acme', 'widgets', webhookURL, webhookSecret); + await github.userClient.executeCommand(`/github subscriptions add ${repo} --features pulls,issues,issue_comments,pushes`, {channel_id: github.channel.id, team_id: github.team.id}); + await fakeGitHub.webhook({repo, sender: github.login, ...request}); + const post = await waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.includes(marker), `webhook post containing "${marker}"`); + const opened = await openChannel(pw, github.user, github.team.name); + return {...opened, post, message: opened.page.locator(`#post_${post.id} .post-message__text`)}; + } + + test('sidebar buttons', async ({pw, github}) => { + await seedWork(github); + const {page} = await openChannel(pw, github.user, github.team.name); + await expect(sidebarButton(page, 'fa-compress')).toHaveText('1'); + await shoot(pw, github, sidebarButtonBar(page), 'sidebar-buttons.png'); + }); + + test('open pull requests in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-compress', longTitle), 'rhs-open-prs.png'); + }); + + test('review requests in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-code-fork', 'Please review the gizmo'), 'rhs-review-requests.png'); + }); + + test('assignments with a milestone in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-list-ol', 'Widgets squeak on Mondays'), 'rhs-assignments.png'); + }); + + test('unread messages in the right-hand panel', async ({pw, github}) => { + await shoot(pw, github, await rhsTab(pw, github, 'fa-envelope', 'Unread Messages'), 'rhs-unreads.png'); + }); + + test('pull request link tooltip', async ({pw, github}) => { + const pr = await seedWork(github); + const url = `https://github.com/${repo}/pull/${pr.number}`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText('Splits polishing into stages.'); + await shoot(pw, github, tooltip, 'tooltip-pull.png', {keepHover: true}); + }); + + test('issue link tooltip', async ({pw, github}) => { + await seedWork(github); + const url = `https://github.com/${repo}/issues/3`; + await github.userClient.createPost({channel_id: github.channel.id, message: url} as any); + const {page} = await openChannel(pw, github.user, github.team.name); + await page.getByRole('link', {name: url}).last().hover(); + const tooltip = page.locator('.github-tooltip .box').first(); + await expect(tooltip).toContainText('Widgets squeak on Mondays'); + await shoot(pw, github, tooltip, 'tooltip-issue.png', {keepHover: true}); + }); + + test('new pull request webhook post', async ({pw, github}) => { + const pr = await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); + await shoot(pw, github, message, 'webhook-new-pull.png'); + }); + + test('merged pull request webhook post', async ({pw, github}) => { + const pr = await seedWork(github); + await fakeGitHub.updateItem(repo, pr.number, {state: 'closed', merged: true}); + const {message} = await webhookPost(pw, github, {event: 'pull_request', action: 'closed', number: pr.number}, 'was merged'); + await shoot(pw, github, message, 'webhook-merged-pull.png'); + }); + + test('new issue webhook post', async ({pw, github}) => { + await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'issues', action: 'opened', number: 3}, '#new-issue'); + await shoot(pw, github, message, 'webhook-new-issue.png'); + }); + + test('issue comment webhook post', async ({pw, github}) => { + await seedWork(github); + const comment = 'Oiled them, still squeaky. Next step is replacing the **bearing**:\n\n- order part\n- swap it'; + const {message} = await webhookPost(pw, github, {event: 'issue_comment', action: 'created', number: 3, comment}, 'New comment by'); + await shoot(pw, github, message, 'webhook-issue-comment.png'); + }); + + test('push webhook post', async ({pw, github}) => { + await seedWork(github); + const {message} = await webhookPost(pw, github, {event: 'push'}, 'pushed [1 new commit]'); + await shoot(pw, github, message, 'webhook-push.png'); + }); + + test('webhook post in the reply thread', async ({pw, github}) => { + const pr = await seedWork(github); + const {channelsPage, post} = await webhookPost(pw, github, {event: 'pull_request', action: 'opened', number: pr.number}, '#new-pull-request'); + await (await channelsPage.centerView.getPostById(post.id)).reply(); + const rhsPost = await channelsPage.sidebarRight.getPostById(post.id); + await shoot(pw, github, rhsPost.container.locator('.post-message__text'), 'reply-thread.png'); + }); + + test('GitHub account in the profile popover', async ({pw, github}) => { + await github.connect(); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Who am I on GitHub?'} as any); + const {channelsPage} = await openChannel(pw, github.user, github.team.name); + const popover = await channelsPage.openProfilePopover(await channelsPage.getLastPost()); + const attribute = popover.container.locator('a[href^="https://github.com/"]').locator('xpath=..'); + await shoot(pw, github, attribute, 'profile-popover.png'); + }); + + test('create issue modal', async ({pw, github}) => { + await seedWork(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Make this an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + const modal = await openCreateIssueModal(channelsPage, page); + await shoot(pw, github, modal.locator('.modal-content'), 'create-issue-modal.png'); + }); + + test('attach to issue modal', async ({pw, github}) => { + await seedWork(github); + await github.userClient.createPost({channel_id: github.channel.id, message: 'Attach this to an issue'} as any); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + const post = await channelsPage.getLastPost(); + await post.hover(); + await post.postMenu.actionsButton.click(); + await page.getByRole('menuitem', {name: 'Attach to GitHub Issue'}).click(); + const modal = page.getByRole('dialog').filter({hasText: 'Attach Message to GitHub Issue'}); + await expect(modal.getByText('Attach this to an issue')).toBeVisible(); + await shoot(pw, github, modal.locator('.modal-content'), 'attach-issue-modal.png'); + }); + + test('/github todo reply', async ({pw, github}) => { + await seedWork(github); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + await runSlashCommand(channelsPage, '/github todo'); + await shoot(pw, github, postWithText(page, 'Unread Messages').locator('.post-message__text'), 'todo.png'); + }); +}); diff --git a/e2e/integration/tests/webhooks.spec.ts b/e2e/integration/tests/webhooks.spec.ts new file mode 100644 index 000000000..e03bef3d9 --- /dev/null +++ b/e2e/integration/tests/webhooks.spec.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {defaultScopes} from '../fake-github/server'; +import {signatures} from '../fake-github/webhooks'; +import {webhookSecret, webhookURL} from '../support/constants'; +import {expect, test, type GitHubFixture} from '../support/fixture'; +import {fakeGitHub} from '../support/fake_github'; +import {botDMPosts, channelPosts, openChannel, postWithText, runSlashCommand, waitForPost} from '../support/ui'; + +const noWebhookNote = 'No webhook was found for this repository or organization'; +const allFeatures = 'pulls,issues,issue_comments,pushes'; + +async function subscribedRepo(github: GitHubFixture) { + const repo = `${github.login}/widgets`; + await fakeGitHub.createRepo(repo); + await fakeGitHub.createHook(github.login, 'widgets', webhookURL, webhookSecret); + await github.connect(); + await github.userClient.executeCommand(`/github subscriptions add ${repo} --features ${allFeatures}`, {channel_id: github.channel.id, team_id: github.team.id}); + return repo; +} + +function waitForChannelPost(github: GitHubFixture, text: string) { + return waitForPost(() => channelPosts(github.adminClient, github.channel.id), (p) => p.message.includes(text), `channel post containing "${text}"`); +} + +test.describe('subscriptions', () => { + test('warns that the repository has no webhook pointing at this server', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); + await expect(postWithText(page, noWebhookNote)).toContainText('/github setup webhook'); + }); + + test('drops the warning once a webhook points at this server', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await fakeGitHub.createHook(github.login, 'widgets', webhookURL, webhookSecret); + await github.connect(undefined, {scopes: [...defaultScopes, 'repo']}); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toContainText(`${github.login}/widgets`); + await expect.poll(() => fakeGitHub.calls()).toContain(`GET /api/repos/${github.login}/widgets/hooks`); + await expect(postWithText(page, noWebhookNote)).toHaveCount(0); + }); + + // Listing repository hooks needs `repo` or `read:repo_hook`; the plugin's public-repo scopes have neither, + // GitHub answers 404, and the plugin treats that as "can't tell" instead of warning. + test('cannot check for a webhook with the default public-repo scopes', async ({pw, github}) => { + await fakeGitHub.createRepo(`${github.login}/widgets`); + await github.connect(); + const {channelsPage, page} = await openChannel(pw, github.user, github.team.name); + + await runSlashCommand(channelsPage, `/github subscriptions add ${github.login}/widgets`); + + await expect(postWithText(page, 'subscribed this channel to')).toBeVisible(); + await expect.poll(async () => (await fakeGitHub.requests()).find((r) => r.path === `/api/repos/${github.login}/widgets/hooks`)?.status).toBe(404); + await expect(postWithText(page, noWebhookNote)).toHaveCount(0); + }); +}); + +test.describe('webhook events', () => { + test('a new pull request is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Add widget polishing', author: github.login, body: 'Polishes widgets.', labels: ['enhancement']}); + + const {deliveries} = await fakeGitHub.webhook({event: 'pull_request', action: 'opened', repo, number: pr.number, sender: github.login}); + + expect(deliveries.map((d) => d.status)).toEqual([200]); + const post = await waitForChannelPost(github, 'Add widget polishing'); + expect(post.message).toContain('#new-pull-request'); + expect(post.message).toContain(`https://github.com/${repo}/pull/${pr.number}`); + }); + + test('a new issue is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets wobble', author: github.login, body: 'They wobble.'}); + + await fakeGitHub.webhook({event: 'issues', action: 'opened', repo, number: issue.number, sender: github.login}); + + const post = await waitForChannelPost(github, 'Widgets wobble'); + expect(post.message).toContain('#new-issue'); + }); + + test('an issue comment is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Widgets squeak', author: github.login}); + + await fakeGitHub.webhook({event: 'issue_comment', action: 'created', repo, number: issue.number, sender: github.login, comment: 'Oiled them, still squeaky.'}); + + const post = await waitForChannelPost(github, 'Oiled them, still squeaky.'); + expect(post.message).toContain('New comment by'); + }); + + test('a push is posted to the subscribed channel', async ({github}) => { + const repo = await subscribedRepo(github); + + await fakeGitHub.webhook({event: 'push', repo, sender: github.login}); + + const post = await waitForChannelPost(github, 'Update README'); + expect(post.message).toContain('pushed [1 new commit]'); + expect(post.message).toContain(`${repo}:main`); + }); + + test('a review request DMs the connected reviewer', async ({pw, github}) => { + const repo = await subscribedRepo(github); + const reviewer = await pw.createNewUserProfile(github.adminClient, {prefix: 'reviewer', disableTutorial: true, disableOnboarding: true}); + await github.adminClient.addToTeam(github.team.id, reviewer.id); + const {login: reviewerLogin} = await github.connect(reviewer); + const pr = await fakeGitHub.createItem(repo, {kind: 'pull', title: 'Review my widget', author: github.login, reviewers: [reviewerLogin]}); + + await fakeGitHub.webhook({event: 'pull_request', action: 'review_requested', repo, number: pr.number, sender: github.login, requestedReviewer: reviewerLogin}); + + const dm = await waitForPost(() => botDMPosts(github.adminClient, reviewer.id), (p) => p.message.includes('requested your review'), 'review request DM'); + expect(dm.message).toContain('Review my widget'); + }); +}); + +test.describe('webhook signatures', () => { + async function deliverRaw(github: GitHubFixture, sign: (body: string) => Record) { + const repo = await subscribedRepo(github); + const issue = await fakeGitHub.createItem(repo, {kind: 'issue', title: 'Tampered issue', author: github.login}); + const {payload} = await fakeGitHub.webhook({event: 'issues', action: 'opened', repo, number: issue.number, sender: github.login, deliver: false}); + const body = JSON.stringify(payload); + const res = await fetch(webhookURL, {method: 'POST', body, headers: {'Content-Type': 'application/json', 'X-GitHub-Event': 'issues', ...sign(body)}}); + const posts = await channelPosts(github.adminClient, github.channel.id); + return {status: res.status, posted: posts.some((p) => p.message.includes('Tampered issue'))}; + } + + test('a delivery without X-Hub-Signature is rejected', async ({github}) => { + expect(await deliverRaw(github, () => ({}))).toEqual({status: 401, posted: false}); + }); + + test('a delivery signed with another secret is rejected', async ({github}) => { + expect(await deliverRaw(github, (body) => signatures('not-the-webhook-secret', body))).toEqual({status: 401, posted: false}); + }); + + test('a delivery signed with the right secret is accepted', async ({github}) => { + expect(await deliverRaw(github, (body) => signatures(webhookSecret, body))).toEqual({status: 200, posted: true}); + }); +}); diff --git a/e2e/integration/tsconfig.json b/e2e/integration/tsconfig.json new file mode 100644 index 000000000..980db1313 --- /dev/null +++ b/e2e/integration/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["es2022", "dom"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/e2e/playwright/mock_oauth_server/mock_oauth_server.ts b/e2e/playwright/mock_oauth_server/mock_oauth_server.ts index 1d9456cd0..b5d7c5f3e 100644 --- a/e2e/playwright/mock_oauth_server/mock_oauth_server.ts +++ b/e2e/playwright/mock_oauth_server/mock_oauth_server.ts @@ -49,5 +49,34 @@ export const makeOAuthServer = ({ app.use(authorizeURLPrefix, oauthRouter); + // E2E builds also send REST, GraphQL and upload calls here; this suite expects them to reach GitHub. + app.use('/api', forwardTo('https://api.github.com')); + app.use('/uploads', forwardTo('https://uploads.github.com')); + return app; }; + +const hopByHopHeaders = ['host', 'connection', 'content-length', 'content-encoding', 'transfer-encoding']; + +function forwardTo(target: string): express.RequestHandler[] { + return [express.raw({type: () => true, limit: '25mb'}), async (req, res) => { + const headers = Object.fromEntries(Object.entries(req.headers).filter(([name]) => !hopByHopHeaders.includes(name))) as Record; + const hasBody = Buffer.isBuffer(req.body) && req.body.length > 0; + + // Express 4 doesn't catch rejected handlers; an unreachable GitHub would otherwise crash the mock. + let upstream: Awaited>; + try { + upstream = await fetch(target + req.url, {method: req.method, headers, body: hasBody ? req.body : undefined}); + } catch (err) { + res.status(502).send(`mock OAuth server could not reach ${target}: ${err}`); + return; + } + + upstream.headers.forEach((value, name) => { + if (!hopByHopHeaders.includes(name)) { + res.setHeader(name, value); + } + }); + res.status(upstream.status).send(Buffer.from(await upstream.arrayBuffer())); + }]; +} diff --git a/server/plugin/graphql/client.go b/server/plugin/graphql/client.go index a606dcb8b..e56d4917d 100644 --- a/server/plugin/graphql/client.go +++ b/server/plugin/graphql/client.go @@ -25,35 +25,34 @@ type Client struct { // NewClient creates and returns Client. The third party package that queries GraphQL is initialized here. func NewClient(logger pluginapi.LogService, getOrganizations func() []string, token oauth2.Token, username, orgName, enterpriseBaseURL string) *Client { - ts := oauth2.StaticTokenSource(&token) - httpClient := oauth2.NewClient(context.Background(), ts) - var client Client - if enterpriseBaseURL == "" { - client = Client{ + return &Client{ username: username, - client: githubv4.NewClient(httpClient), + client: githubv4.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&token))), logger: logger, org: orgName, getOrganizations: getOrganizations, } - } else { - baseURL, err := url.JoinPath(enterpriseBaseURL, "api", "graphql") - if err != nil { - logger.Debug("Not able to parse the enterprise URL", "error", err.Error()) - return nil - } + } - client = Client{ - client: githubv4.NewEnterpriseClient(baseURL, httpClient), - username: username, - org: orgName, - logger: logger, - getOrganizations: getOrganizations, - } + baseURL, err := url.JoinPath(enterpriseBaseURL, "api", "graphql") + if err != nil { + logger.Debug("Not able to parse the enterprise URL", "error", err.Error()) + return nil } - return &client + return NewClientWithURL(logger, getOrganizations, token, username, orgName, baseURL) +} + +// NewClientWithURL creates a Client for the GraphQL endpoint at graphQLURL. +func NewClientWithURL(logger pluginapi.LogService, getOrganizations func() []string, token oauth2.Token, username, orgName, graphQLURL string) *Client { + return &Client{ + client: githubv4.NewEnterpriseClient(graphQLURL, oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&token))), + username: username, + org: orgName, + logger: logger, + getOrganizations: getOrganizations, + } } // executeQuery takes a query struct and sends it to Github GraphQL API via helper package. diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 079bfd172..a306ef48f 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -63,7 +63,8 @@ const ( invalidTokenError = "401 Bad credentials" //#nosec G101 -- False positive ) -// testOAuthServerURL is the URL for the oauthServer used for testing purposes +// testOAuthServerURL is the URL of a fake GitHub used for testing purposes. When set, it replaces +// github.com for OAuth, api.github.com (under /api) and uploads.github.com (under /uploads). // It should be set through ldflags when compiling for E2E, and keep it blank otherwise var testOAuthServerURL = "" @@ -181,6 +182,9 @@ func (p *Plugin) githubConnectUser(_ context.Context, info *GitHubUserInfo) *git func (p *Plugin) graphQLConnect(info *GitHubUserInfo) *graphql.Client { conf := p.getConfiguration() + if testOAuthServerURL != "" && conf.EnterpriseBaseURL == "" { + return graphql.NewClientWithURL(p.client.Log, p.configuration.getOrganizations, *info.Token, info.GitHubUsername, conf.GitHubOrg, testOAuthServerURL+"/api/graphql") + } return graphql.NewClient(p.client.Log, p.configuration.getOrganizations, *info.Token, info.GitHubUsername, conf.GitHubOrg, conf.EnterpriseBaseURL) } @@ -205,7 +209,17 @@ func GetGitHubClient(token oauth2.Token, config *Configuration) (*github.Client, func getGitHubClient(authenticatedClient *http.Client, config *Configuration) (*github.Client, error) { if config.EnterpriseBaseURL == "" || config.EnterpriseUploadURL == "" { - return github.NewClient(authenticatedClient), nil + client := github.NewClient(authenticatedClient) + if testOAuthServerURL != "" { + var err error + if client.BaseURL, err = url.Parse(testOAuthServerURL + "/api/"); err != nil { + return nil, err + } + if client.UploadURL, err = url.Parse(testOAuthServerURL + "/uploads/"); err != nil { + return nil, err + } + } + return client, nil } baseURL, err := url.JoinPath(config.EnterpriseBaseURL, "api", "v3") if err != nil { @@ -251,6 +265,10 @@ func (p *Plugin) setDefaultConfiguration() error { func (p *Plugin) OnActivate() error { p.ensurePluginAPIClient() + if testOAuthServerURL != "" { + p.client.Log.Warn("GitHub OAuth, REST and GraphQL endpoints are overridden; this build is for E2E testing only", "url", testOAuthServerURL) + } + _, err := getSiteURL(p.client) if err != nil { return err diff --git a/server/plugin/plugin_test.go b/server/plugin/plugin_test.go index 8b565d07d..2c1afbb66 100644 --- a/server/plugin/plugin_test.go +++ b/server/plugin/plugin_test.go @@ -625,3 +625,18 @@ func TestUseGitHubClient_AuthFailureNotifiesUser(t *testing.T) { }) } } + +func TestGetGitHubClientWithTestServerURL(t *testing.T) { + original := testOAuthServerURL + testOAuthServerURL = "http://fake-github:8093" + t.Cleanup(func() { testOAuthServerURL = original }) + + client, err := getGitHubClient(http.DefaultClient, &Configuration{}) + require.NoError(t, err) + require.Equal(t, "http://fake-github:8093/api/", client.BaseURL.String()) + require.Equal(t, "http://fake-github:8093/uploads/", client.UploadURL.String()) + + enterprise, err := getGitHubClient(http.DefaultClient, &Configuration{EnterpriseBaseURL: "https://ghe.example.com", EnterpriseUploadURL: "https://ghe.example.com"}) + require.NoError(t, err) + require.Equal(t, "https://ghe.example.com/api/v3/", enterprise.BaseURL.String()) +} diff --git a/webapp/src/components/github_repo_selector/github_repo_selector.jsx b/webapp/src/components/github_repo_selector/github_repo_selector.jsx index 7ad3fd393..87ecc4022 100644 --- a/webapp/src/components/github_repo_selector/github_repo_selector.jsx +++ b/webapp/src/components/github_repo_selector/github_repo_selector.jsx @@ -63,12 +63,14 @@ export default class GithubRepoSelector extends PureComponent { if (prevProps.yourOrgs !== this.props.yourOrgs && this.props.yourOrgs.length > 0) { const newOrg = this.props.yourOrgs[0].login; if (this.state.org !== newOrg) { - this.onChangeForOrg(newOrg); + this.selectOrg(newOrg); } } } - onChangeForOrg = (org) => { + onChangeForOrg = (_, org) => this.selectOrg(org); + + selectOrg = (org) => { if (this.state.org !== org) { this.setState({org}); this.props.actions.getReposByOrg(org, this.props.currentChannelId);