diff --git a/.env.example b/.env.example index d98f7d4b9..1084e9837 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,32 @@ # Uncomment this to get GitHub comments for the Pull Request Workflow. # ENABLE_PR_COMMENT=true + +# ADMIN_REPO=safe-settings-config +CONFIG_PATH=.github +SETTINGS_FILE_PATH=settings.yml + +# URL prefix for deployment behind a proxy (appears in browser address bar) +# Default: /safe-settings +# Set to empty string for root path deployment: SAFE_SETTINGS_HUB_URL_PREFIX= +# SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings + +# Configuration support for Hub-Sync safe-settings feature +# SAFE_SETTINGS_HUB_REPO=safe-settings-config-master +# SAFE_SETTINGS_HUB_ORG=foo-training +# A subfolder under 'CONFIG_PATH' where the 'organizations//' structure is found +# SAFE_SETTINGS_HUB_PATH=safe-settings +# SAFE_SETTINGS_HUB_DIRECT_PUSH=true + + + +# ┌────────────── second (optional) +# │ ┌──────────── minute +# │ │ ┌────────── hour +# │ │ │ ┌──────── day of month +# │ │ │ │ ┌────── month +# │ │ │ │ │ ┌──── day of week +# │ │ │ │ │ │ +# │ │ │ │ │ │ +# * * * * * * +# CRON=* * * * * # Run every minute \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9cd65700b..0018fb16a 100644 --- a/.gitignore +++ b/.gitignore @@ -128,7 +128,7 @@ npm-debug.log .DS_Store node_modules/ private-key.pem -.env +*.env *.pem .vscode yarn.lock @@ -140,3 +140,6 @@ samconfig.toml # test file to be ignored test.log reports + +#all general log files +*.log \ No newline at end of file diff --git a/README.md b/README.md index 07a626748..ff1f0237f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > It is possible specify a custom repo instead of the `admin` repo with `ADMIN_REPO`. See [Environment variables](#environment-variables) for more details. -1. The **settings** in the **default** branch are applied. If the settings are changed on a non-default branch and a PR is created to merge the changes, the app runs in a `dry-run` mode to evaluate and validate the changes. Checks pass or fail based on the `dry-run` results. +1. The **settings** in the **default** branch are applied. If the settings are changed on a non-default branch and a PR is created to merge the changes, the app runs in a `dry-run` mode to evaluate and validate the changes. Checks pass or fail based on the `dry-run` results. The dry-run compares the PR's config against the **base branch** config, so the check run and PR comment report only the changes the PR itself introduces (see [Dry-run PR comment](#dry-run-pr-comment)). 1. In `safe-settings` the settings can have 2 types of targets: 1. `org` - These settings are applied to the organization. `Org`-targeted settings are defined in `.github/settings.yml`. Currently, only `rulesets` are supported as `org`-targeted settings. @@ -168,7 +168,7 @@ The App listens to the following webhook events: - **repository.renamed**: If a repository is renamed, the default behavior is safe-settings will ignore this (for backward-compatibility). If `BLOCK_REPO_RENAME_BY_HUMAN` env variable is set to true, `safe-settings` will revert the repo to the previous name unless it is renamed using a `bot`. If it is renamed using a `bot`, it will try to copy the existing `.yml` to `.yml` so that the repo config yml stays consistent. If a file already exists, it doesn't create a new one. -- **pull_request.opened**, **pull_request.reopened**, **check_suite.requested**: If the settings are changed, but it is not in the `default` branch, and there is an existing PR, the code will validate the settings changes by running safe-settings in `nop` mode and update the PR with the `dry-run` status. +- **pull_request.opened**, **pull_request.reopened**, **check_suite.requested**: If the settings are changed, but it is not in the `default` branch, and there is an existing PR, the code will validate the settings changes by running safe-settings in `nop` mode and update the PR with the `dry-run` status. The run loads the base-branch config and filters the results so only the changes the PR introduces are reported (see [Dry-run PR comment](#dry-run-pr-comment)). - **repository_ruleset**: If the `ruleset` settings are modified in the UI manually, `safe-settings` will `sync` the settings to prevent any unauthorized changes. @@ -178,6 +178,42 @@ The App listens to the following webhook events: - __custom_property_values__: If new repository properties are set for a repository, `safe-settings` will run to so that if a sub-org config is defined by that property, it will be applied for the repo +- **repository_dispatch** (`event_type: safe-settings-generate`): Triggers the **settings generator**, which reads the current configuration of a repo/org/suborg and opens a PR against the `admin` repo with the generated YAML. See [Generating settings from existing configuration](#generating-settings-from-existing-configuration). + +### Dry-run PR comment + +When a config change is proposed in a PR (a non-default branch), `safe-settings` runs in `nop` (no-operation) `dry-run` mode and posts a comment summarizing what *would* change if the PR were merged. The results are filtered against the **base branch** config, so the comment reports only the changes the PR introduces — not the full diff against live GitHub settings. + +The comment contains: + +- A header with the run timestamp, the **number of repos considered**, and the **number of repos affected**. +- **Breakdown of changes** — a collapsible section, grouped by plugin/repo, showing field-level diffs. Each entry is marked as an addition, modification, or deletion, with the before/after values for modified fields. When there are no changes, it shows `No changes to apply.` +- **Breakdown of errors** — a collapsible section listing any errors by repo, or `None` when there are none. The check run is marked as failed when errors are present. +- **Informational messages** — a collapsible section listing non-error notices such as plugins skipped via [`disable_plugins`](#disabling-plugins-disable_plugins) or deletions suppressed by `additive_plugins`, so reviewers can see which settings were intentionally not applied. + +For very large diffs the comment is split across multiple comments, and the check-run summary is truncated with a notice when it exceeds the size limit. + +### Suborg re-evaluation after repo-level changes + +A repo's suborg membership can depend on state that is itself written by `safe-settings`: + +- `suborgteams` — repos belong to a suborg because a given team is granted access +- `suborgproperties` — repos belong to a suborg because a custom property has a given value +- `suborgrepos` — repos belong to a suborg because their name matches a glob + +When a repo-level change (a push to `.github/repos/.yml`, or a `repository.created` event for a brand-new repo) adds, removes, or changes a team or custom property, the repo may start or stop matching a suborg config. A new repo may also start matching a suborg because its name matches a `suborgrepos` glob. + +To handle this, after applying a repo-yml change `safe-settings` re-evaluates the repo's suborg membership. If the matched suborg source set changed, it runs the repo through the apply pipeline a second time so newly matched suborg settings are applied and settings from a no-longer-matching suborg can be removed in the same sync. + +**Scope:** Re-evaluation runs only on the repo-yml change paths (`Settings.sync` and the per-repo loop of `Settings.syncSelectedRepos`). Global settings changes (`syncAll`) and suborg-yml changes (`syncSubOrgs`) already iterate all relevant repos and do not need it. + +**Loop prevention.** Two guards prevent infinite re-evaluation: + +1. **Stability check (primary):** Before applying changes, `safe-settings` snapshots the set of suborg source paths that match the repo. After applying, it refreshes the suborg cache and recomputes the set. If the set did not change, re-evaluation stops. If a source appeared or disappeared, the repo is processed once more. +2. **Hard depth cap (safety net):** Each repo is re-evaluated at most `MAX_REEVALUATION_DEPTH = 1` time per sync. This resolves the dominant single-hop case (repo change → suborg membership changed → apply the corrected suborg overlay once) while preventing pathological chains (suborg A applies a team that activates suborg B that activates suborg C…). Chains beyond one hop are resolved on the next sync event, and a warning is logged when the cap is hit. + +**Trigger optimization.** Re-evaluation is skipped entirely when the applied repo change did not affect `teams`, `custom_properties`, repository creation, or repository rename state — these are the repo-level changes that can affect suborg matching. + ### Use `safe-settings` to rename repos If you rename a `` that corresponds to a repo, safe-settings will rename the repo to the new name. This behavior will take effect whether the env variable `BLOCK_REPO_RENAME_BY_HUMAN` is set or not. @@ -301,6 +337,60 @@ Notes: > ⚠️ **Warning:** When `{{EXTERNALLY_DEFINED}}` is removed from an existing branch protection rule or ruleset configuration, the status checks in the existing rules in GitHub will revert to the checks that are defined in safe-settings. From this point onwards, all status checks configured through the GitHub UI will be reverted back to the safe-settings configuration. +#### Referencing ruleset bypass actors and reviewers by name + +Rulesets normally require numeric ids for `bypass_actors[].actor_id` and for the +team in `required_reviewers[].reviewer.id`. To avoid looking these ids up, you +can reference them by name and safe-settings resolves them to the correct id +before applying the ruleset: + +- `bypass_actors[].name` — an alternative to `actor_id`. The value is resolved + based on `actor_type`: + - `Team` → team slug + - `User` → username + - `Integration` → GitHub App slug + - `RepositoryRole` → role name. Built-in roles (`read`, `triage`, `write`, + `maintain`, `admin`) are mapped automatically; any other name is looked up + among the organization's custom repository roles. +- `required_reviewers[].reviewer.slug` — an alternative to `reviewer.id`, the + slug of the reviewing team. + +```yaml +rulesets: + - name: Main protection + target: branch + enforcement: active + bypass_actors: + - name: my-team # resolved to actor_id + actor_type: Team + bypass_mode: always + - name: admin # built-in repository role + actor_type: RepositoryRole + bypass_mode: always + rules: + - type: pull_request + parameters: + required_approving_review_count: 1 + dismiss_stale_reviews_on_push: false + require_code_owner_review: false + require_last_push_approval: false + required_review_thread_resolution: false + required_reviewers: + - minimum_approvals: 1 + file_patterns: ["*.js"] + reviewer: + slug: my-reviewers-team # resolved to reviewer.id + type: Team +``` + +Notes: + - This is fully backward compatible. Existing policies that use `actor_id` / + `reviewer.id` continue to work unchanged, and numeric ids are never looked up. + - Provide either the name (`name` / `slug`) or the id (`actor_id` / + `reviewer.id`) for a given entry, not both. Specifying both is an error. + - If a name cannot be resolved to an id, the ruleset sync fails with a clear + error so the misconfiguration is surfaced rather than silently ignored. + #### Status checks inheritance across scopes Refer to [Status checks](docs/status-checks.md). @@ -451,6 +541,126 @@ And the `checkrun` page will look like this: image

+### Disabling plugins (`disable_plugins`) + +Any settings file (deployment-settings, org `settings.yml`, suborg, or repo) can +contain a top-level `disable_plugins` list to turn off one or more safe-settings +plugins for a given scope. Each entry is either: + +- A plugin name string (shorthand for `{ plugin: , target: all }`), or +- An object `{ plugin: , target: self | children | all }` (default `target: all`). + +Valid plugin names: `repository`, `labels`, `collaborators`, `teams`, +`milestones`, `branches`, `autolinks`, `validator`, `rulesets`, `environments`, +`custom_properties`, `custom_repository_roles`, `variables`, `archive`. + +#### Strip matrix (which source layers are removed before merge) + +| Declared at | `target: self` | `target: children` | `target: all` | +| -------------------------- | ------------------ | ------------------------- | ----------------------------- | +| deployment-settings | deployment | org + suborg + repo | deployment + org + suborg + repo | +| org `settings.yml` | org | suborg + repo | org + suborg + repo | +| suborgs/`*.yml` (matched) | suborg | repo | suborg + repo | +| repos/`*.yml` | repo | (no-op) | repo | + +When safe-settings builds the merged configuration for a repo, it strips the +disabled plugin's keys from the indicated source layers before merging. For +repo-level execution points (the `repository` and `archive` plugins) and +org-level execution points (`rulesets`, `custom_repository_roles`), a disable +that targets the corresponding layer also short-circuits the plugin run, and +the skip is recorded as an INFO `NopCommand` in NOP mode (PR check run). + +#### Cascade rules + +- **Union-only.** Strips accumulate across layers; a lower-level config can add + more strips but can never undo a strip declared above it. +- **No re-enable.** If `disable_plugins: [labels]` is set at the org layer, a + repo cannot re-enable `labels` for itself. + +#### Important limitation + +Because strips operate on **source layers**, a lower-level disable cannot +remove configuration contributed by a higher layer. For example, if `branches` +is defined at the org layer and a suborg adds +`disable_plugins: [{plugin: branches, target: all}]`, the suborg's strip +removes the `branches` key only from the suborg and repo layers — the org's +`branches` config still merges in, and the branches plugin still runs. + +To fully suppress a plugin for matched repos, declare the disable at (or above) +the layer that contributes the configuration — typically the org layer with +`target: all`, or at the deployment layer. + +#### Examples + +Org `settings.yml` — disable `custom_repository_roles` only at the org execution +point (rulesets still run): + +```yaml +disable_plugins: + - plugin: custom_repository_roles + target: self +``` + +Org `settings.yml` — disable `branches` everywhere (shorthand): + +```yaml +disable_plugins: + - branches +``` + +Suborg `suborgs/team-x.yml` — strip `labels` for matched repos (effective only +if `labels` is not also defined at the org layer): + +```yaml +disable_plugins: + - plugin: labels + target: all +``` + +### Additive plugins (`additive_plugins`) + +`additive_plugins` is the complementary "soft mode" to `disable_plugins`. When a +Diffable plugin is listed here, safe-settings will only **add** and **update** +entries — it will **never call `remove()`**. Items that exist on GitHub but are +absent from the YAML are preserved, effectively merging external changes with +your policy rather than overwriting them. + +Declare `additive_plugins` only in `settings.yml` (org level) to keep behaviour +consistent across all repositories. + +**Supported plugins** (all extend `Diffable`): + +| Plugin | Effect in additive mode | +|--------|------------------------| +| `labels` | Extra labels not in YAML are kept | +| `collaborators` | Extra collaborators not in YAML are kept | +| `teams` | Extra team permissions not in YAML are kept | +| `milestones` | Extra milestones not in YAML are kept | +| `autolinks` | Extra autolinks not in YAML are kept | +| `environments` | Extra environments not in YAML are kept | +| `custom_properties` | Extra property values not in YAML are kept | +| `variables` | Extra variables not in YAML are kept | +| `rulesets` | Extra rulesets not in YAML are kept | +| `custom_repository_roles` | Extra custom roles not in YAML are kept | + +> [!important] +> `repository`, `archive`, `branches`, and `validator` are **not** supported. +> Listing them in `additive_plugins` will produce a validation error. + +**NOP mode**: when `additive_plugins` is active and the diff would produce +deletions, an informational message — *"Additive mode active: N deletion(s) +suppressed by additive_plugins"* — is included in the PR check-run comment so +reviewers can see what is being preserved. + +**Example** — never delete labels or collaborators that were added outside of +safe-settings: + +```yaml +additive_plugins: + - labels + - collaborators +``` + ### The Settings Files The settings files can be used to set the policies at the `org`, `suborg` or `repo` level. @@ -573,7 +783,215 @@ You can pass environment variables; the easiest way to do it is via a `.env` fil 3. __[Deploy and install the app](docs/deploy.md)__. Alternatively, the __[GitHub Actions Guide](docs/github-action.md)__ describes how to run `safe-settings` with GitHub Actions. +## Smoke Testing + +The repository includes an end-to-end smoke test script (`smoke-test.js`) that validates safe-settings against a live GitHub organization. It starts the app, creates repos/configs via the API, and verifies that safe-settings correctly applies and enforces settings. + +### Prerequisites + +- **Node.js** (same version used to run safe-settings) +- **`gh` CLI** — authenticated and available on PATH (used for drift-remediation tests only) +- A **GitHub App** installed on the target org with the required permissions +- A `.env` file in the project root (see below) + +### Authentication + +The smoke test uses **two authentication methods**: + +- **GitHub App token** (via `APP_ID` + `PRIVATE_KEY`) — used for the majority of tests: creating configs, merging PRs, validating repos, teams, rulesets, custom properties, etc. +- **Fine-grained PAT** (via `GH_TOKEN`) — used **only** in Phase 2 (team removal) and Phase 3 (rogue ruleset creation). These drift-remediation tests must appear as a human action because safe-settings ignores webhook events where `sender.type` is `Bot`. + +### Configuration + +Add the following to your `.env` file: + +| Variable | Description | Required | +|---|---|---| +| `GH_ORG` | Target GitHub organization (e.g. `my-org`) | Yes | +| `APP_ID` | GitHub App ID | Yes | +| `PRIVATE_KEY` | GitHub App private key (use `\n` for newlines) | Yes | +| `WEBHOOK_PROXY_URL` | Smee.io proxy URL for webhooks | Yes | +| `ADMIN_REPO` | Admin repo name (default: `admin`) | No | +| `CONFIG_PATH` | Config path within admin repo (default: `.github`) | No | +| `GH_TOKEN` | Fine-grained PAT with org admin + repo permissions | Yes | +| `SMOKE_VERBOSE` | Set to `1` to show live safe-settings logs | No | + +### Running + +```bash +# Run all phases +npm run smoke-test +# or +node smoke-test.js + +# Interactive mode — pause after each phase for manual validation +npm run smoke-test:interactive +# or +node smoke-test.js --interactive + +# Run a single phase (with setup + teardown) +npm run smoke-test:phase -- 3 +# or +node smoke-test.js --phase 3 + +# Run a range of phases +npm run smoke-test:phase -- 1-3 +node smoke-test.js --phase 1-3 + +# Run specific comma-separated phases +npm run smoke-test:phase -- 1,3,5 +node smoke-test.js --phase 1,3,5 + +# Mix range + interactive +npm run smoke-test:phase -- 1-3 interactive +node smoke-test.js --phase 1-3 --interactive +``` + +### What it tests + +The smoke test runs the following phases: + +| Phase | Description | +|---|---| +| **Setup** | Initializes the admin repo with an empty `settings.yml`, removes stale test repos, and starts safe-settings | +| **Phase 1** | Creates a repo config (`test`), validates NOP mode via check runs, merges, and verifies repo creation, teams, custom properties, and rulesets | +| **Phase 2** | Removes a team from the repo and verifies safe-settings re-adds it (drift remediation) | +| **Phase 3** | Creates a rogue ruleset and verifies safe-settings removes it (drift remediation) | +| **Phase 4** | Creates `demo-repo-service1` with teams, topics, and branch protection | +| **Phase 5** | Creates a property-targeted suborg config, verifies suborg rulesets apply to two matching repos, then changes one repo's custom property and verifies the ruleset is removed only from the repo that no longer matches | +| **Phase 6** | Archives `demo-repo-service1` and verifies the repo is archived | +| **Phase 7** | Creates `demo-repo-service2` and verifies suborg rulesets are inherited | +| **Phase 7b** | Tests external group team sync | +| **Phase 8** | Creates org-level settings (custom repository roles + org rulesets) and verifies they are applied | +| **Phase 10** | Validates `disable_plugins` — ensures disabled plugins are skipped | +| **Phase 11** | Validates `additive_plugins` — verifies additive-mode plugin behaviour | +| **Phase 12** | Tests `custom_properties` plugin | +| **Phase 13** | Tests the `variables` plugin (create, update, remove variables) | +| **Teardown** | Shuts down safe-settings, deletes test repos, teams, custom roles, and rulesets | + +### Output + +The script uses colored terminal output with pass (✅) / fail (❌) indicators and prints a summary at the end: + +``` +══════════════════════════════════════ + Results: 45 passed, 0 failed +══════════════════════════════════════ +``` + + +## Generating settings from existing configuration + +Safe-settings normally works "forward": you declare settings in YAML and it applies them to GitHub. The **settings generator** does the reverse — it reads the *current* state of a repo, an org, or a collection of repos (a suborg) and produces the corresponding safe-settings YAML (`repos/.yml`, `settings.yml`, or `suborgs/.yml`). This is useful for onboarding existing repositories/orgs onto safe-settings without hand-authoring config. + +It can be invoked two ways: + +- **Standalone CLI** (`generate-settings.js`) — writes the generated file to your local filesystem. +- **App trigger** via a `repository_dispatch` event — the running app generates the file and opens a **pull request** against the admin repo. + +### Source types + +| `source_type` | `source_value` | What is extracted | Output file | +|---|---|---|---| +| `repo` | repository name | All repo-level plugins (repository, labels, collaborators, teams, milestones, branches, autolinks, custom_properties, variables, environments, repo rulesets) | `repos/.yml` | +| `org` | org login | Org-level rulesets and custom repository roles only | `settings.yml` | +| `custom-property` | `name=value` (e.g. `Team=backend`) | Repo-level settings **common to all repos** carrying that custom property value (intersection) | `suborgs/_.yml` | + +> **Note on suborgs:** for `custom-property`, the generator discovers every repo with the given custom property value, extracts each repo's config, and keeps only the settings that are **identical across all of them**. A `suborgproperties` selector is prepended automatically. + +### Overwrite behavior + +By default (`overwrite=false`) the generator will **not** replace an existing file. If the target already exists it writes a `.sample.yml` file next to it instead. Set `overwrite=true` to replace the file. + +### Standalone invocation + +The CLI loads variables from a `.env` file in the project root (`APP_ID`, `PRIVATE_KEY`, and optionally `GH_ORG`/`OWNER`). Options can be passed as flags or environment variables. + +```bash +# Generate repos/my-repo.yml from a single repository +node generate-settings.js \ + --source-type repo \ + --source-value my-repo \ + --owner my-org \ + --output-dir ./out + +# Generate settings.yml from org-level rulesets + custom repository roles +node generate-settings.js --source-type org --source-value my-org --output-dir ./out + +# Generate suborgs/Team_backend.yml from all repos with the custom property Team=backend +node generate-settings.js \ + --source-type custom-property \ + --source-value "Team=backend" \ + --owner my-org \ + --output-dir ./out + +# Overwrite an existing file instead of writing a .sample.yml +node generate-settings.js --source-type repo --source-value my-repo --owner my-org --overwrite + +# Using environment variables instead of flags +SOURCE_TYPE=repo SOURCE_VALUE=my-repo OWNER=my-org OUTPUT_DIR=./out node generate-settings.js +``` + +| Flag | Env var | Description | Default | +|---|---|---|---| +| `--source-type` | `SOURCE_TYPE` | `repo`, `org`, or `custom-property` | (required) | +| `--source-value` | `SOURCE_VALUE` | repo name / org login / `name=value` | (required) | +| `--property-name` | `SOURCE_PROPERTY_NAME` | Custom property name (alternative to encoding it in `--source-value`) | — | +| `--owner` | `OWNER` / `GITHUB_ORG` / `GH_ORG` | Org login (selects the matching App installation) | first installation | +| `--output-dir` | `OUTPUT_DIR` | Directory to write generated files into | `.` | +| `--overwrite` | `OVERWRITE=true` | Replace existing files instead of writing `.sample.yml` | `false` | + +### App invocation (`repository_dispatch`) + +When the app is running, trigger generation by sending a `repository_dispatch` event (with `event_type: safe-settings-generate`) to the **admin repo**. The app generates the file and opens a PR against the admin repo's default branch. + +```bash +# Generate a repo config and open a PR +gh api --method POST \ + /repos/my-org/admin/dispatches \ + -f event_type=safe-settings-generate \ + -F 'client_payload[source_type]=repo' \ + -F 'client_payload[source_value]=my-repo' \ + -F 'client_payload[overwrite]=false' + +# Generate org-level settings.yml and open a PR +gh api --method POST \ + /repos/my-org/admin/dispatches \ + -f event_type=safe-settings-generate \ + -F 'client_payload[source_type]=org' \ + -F 'client_payload[source_value]=my-org' + +# Generate a suborg config from a custom property +gh api --method POST \ + /repos/my-org/admin/dispatches \ + -f event_type=safe-settings-generate \ + -F 'client_payload[source_type]=custom-property' \ + -F 'client_payload[source_value]=Team=backend' \ + -F 'client_payload[overwrite]=false' +``` + +The `client_payload` fields are: + +| Field | Description | Required | +|---|---|---| +| `source_type` | `repo`, `org`, or `custom-property` | Yes | +| `source_value` | repo name / org login / `name=value` | Yes | +| `property_name` | Custom property name (alternative to encoding it in `source_value`) | No | +| `overwrite` | `true` to replace an existing file; otherwise a `.sample.yml` is created | No (default `false`) | + +> **Tip:** Always review the generated PR before merging. Running safe-settings in NOP mode against the generated config should report no unexpected diffs. + +#### Generated changes always go through a pull request + +The app **never** commits generated configuration directly to the admin repo's default branch. Every `repository_dispatch` invocation produces a pull request that must be reviewed and merged before it takes effect. Concretely, for each request the app: + +1. Creates a **new branch** off the admin repo's default branch (`safe-settings-generate/--`). +2. Commits the generated YAML **to that branch only**. +3. Opens a **pull request** from that branch against the default branch. + +This means it is safe to give developers write access to the admin repo so they can trigger generation: a `repository_dispatch` event can only create a branch and open a PR — it cannot change the live configuration on its own. The generated config does not reach the path safe-settings acts on until the PR is merged, so all changes are subject to your normal review process and any branch protection / required-reviews rules configured on the admin repo's default branch. +To enforce review, protect the admin repo's default branch (for example, require pull request reviews and disallow direct pushes). Because the generator only ever writes to a feature branch and opens a PR, those rules apply to every generated change. ## License diff --git a/app.yml b/app.yml index 04b1f7015..e61ffdf30 100644 --- a/app.yml +++ b/app.yml @@ -22,10 +22,10 @@ default_events: - pull_request - push - repository + - repository_dispatch - repository_ruleset - team - # The set of permissions needed by the GitHub App. The format of the object uses # the permission name for the key (for example, issues) and the access type for # the value (for example, write). @@ -114,6 +114,14 @@ default_permissions: # https://developer.github.com/v3/apps/permissions/ organization_administration: write + # Manage custom organization roles. + # https://docs.github.com/en/enterprise-cloud@latest/rest/authentication/permissions-required-for-github-apps?apiVersion=2026-03-10#organization-permissions-for-custom-organization-roles + organization_custom_org_roles: write + + # Manage custom repository roles. + # https://docs.github.com/en/enterprise-cloud@latest/rest/authentication/permissions-required-for-github-apps?apiVersion=2026-03-10#organization-permissions-for-custom-repository-roles + organization_custom_roles: write + # Manage Actions variables. # https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28 actions_variables: write diff --git a/docs/README.md b/docs/README.md index 6d1f17436..76c9ee10f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,3 +10,5 @@ | Configure deployment environments | [Deployment Environments](github-settings/6.%20deployment-environments.md) | | Configure auto-link references | [AutoLinks](github-settings/7.%20autolinks.md) | | Configure pre-defined labels for issues and pull requests | [Labels](github-settings/8.%20labels.md) | + +For information on disabling plugins, see [Disabling plugins](../README.md#disabling-plugins-disable_plugins) in the root README. diff --git a/docs/github-settings/4. teams.md b/docs/github-settings/4. teams.md index 496b30a32..565398a1e 100644 --- a/docs/github-settings/4. teams.md +++ b/docs/github-settings/4. teams.md @@ -38,7 +38,7 @@ teams:

permissionstring

-

The permission to grant the team on this repository. We accept the following permissions to be set: pull, triage, push, maintain, admin and you can also specify a custom repository role name, if the owning organization has defined any. If no permission is specified, the team's permission attribute will be used to determine what permission to grant the team on this repository.

+

The permission to grant the team on this repository. We accept the following permissions to be set: pull, triage, push, maintain, admin and you can also specify a custom repository role name, if the owning organization has defined any.

Default: push

@@ -48,5 +48,18 @@ teams: permission: maintain ``` + + +

external_groupstring

+

Optional. The display name of an external IdP group (as listed under your organization's external groups) to link to the team. safe-settings looks up the group's id by display name via GET /orgs/{org}/external-groups and links the team via PATCH /orgs/{org}/teams/{team_slug}/external-groups. The link is reconciled on every sync and is idempotent (it skips the PATCH when the team is already linked to the same group). The external-groups list is fetched at most once per org per sync, only when at least one team entry uses this property. If the named group does not exist for the org, an error is logged and the team-repo association still applies.

+ + +```yaml +teams: + - name: expert-services-developers + permission: push + external_group: "Engineering - Expert Services" +``` + diff --git a/docs/hubSyncHandler/BASE_PATH.md b/docs/hubSyncHandler/BASE_PATH.md new file mode 100644 index 000000000..548fbae76 --- /dev/null +++ b/docs/hubSyncHandler/BASE_PATH.md @@ -0,0 +1,141 @@ +# URL Prefix Configuration + +Safe Settings supports deployment behind a reverse proxy (like NGINX) that routes to the application using a custom URL prefix. + +## Overview + +By default, Safe Settings serves its UI and API from `/safe-settings`: +- Dashboard: `http://localhost:3000/safe-settings/dashboard` +- API: `http://localhost:3000/safe-settings/api/safe-settings/...` + +You can customize this by setting the `SAFE_SETTINGS_HUB_URL_PREFIX` environment variable, or set it to an empty string for root path deployment: +- Dashboard (root): `http://localhost:3000/dashboard` +- API (root): `http://localhost:3000/api/safe-settings/...` + +## Configuration + +### Default Behavior + +Safe Settings defaults to `SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings`. No configuration needed for this default. + +### Customizing the URL Prefix + +To use a different URL prefix, add to your `.env` file: + +```bash +SAFE_SETTINGS_HUB_URL_PREFIX=/my-custom-path +``` + +### Root Path Deployment + +To deploy at the root path instead, set SAFE_SETTINGS_HUB_URL_PREFIX to an empty string: + +```bash +SAFE_SETTINGS_HUB_URL_PREFIX= +``` + +**Important:** +- The SAFE_SETTINGS_HUB_URL_PREFIX will automatically add a leading `/` if you forget it +- Examples: `/safe-settings`, `safe-settings`, `/apps/safe-settings`, `custom-prefix` (all work!) +- Do NOT end with `/` +- Set to empty string or `/` for root path deployment + +### 2. Rebuild the UI + +After changing the SAFE_SETTINGS_HUB_URL_PREFIX, you must rebuild the Next.js UI: + +```bash +cd ui +npm run build +cd .. +``` + +### 3. Restart the application + +```bash +npm start +# or +npm run dev +``` + +## NGINX Configuration Example + +Here's an example NGINX configuration for routing requests to Safe Settings at `/safe-settings`: + +Make sure to set `SAFE_SETTINGS_HUB_URL_PREFIX=/safe-settings` in your `.env` file before starting the application. + +```nginx +server { + listen 80; + server_name your-domain.com; + + # Route /safe-settings to Safe Settings application + location /safe-settings { + proxy_pass http://localhost:3000/safe-settings; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Route other paths to different applications + location /other-app { + proxy_pass http://localhost:4000; + # ... other proxy settings + } +} +``` + +## How It Works + +The SAFE_SETTINGS_HUB_URL_PREFIX configuration affects three layers: + +1. **Backend Routing** (`lib/routes.js`): Express router is mounted at the SAFE_SETTINGS_HUB_URL_PREFIX instead of root +2. **Next.js Configuration** (`ui/next.config.js`): The `basePath` setting tells Next.js to generate assets with the correct URL prefix +3. **Frontend Links & API Calls** (`ui/src/app/**`): Navigation links and API fetch calls use the `withBasePath()` utility to prepend the URL prefix + +All API endpoints in the frontend components (`EnvVariables.jsx`, `OrganizationsTable.jsx`, `Safe-settings-hubContent.jsx`, `HubOrgGraph.jsx`) have been updated to use `withBasePath()` for proper routing. + +## Testing Locally + +To test the default SAFE_SETTINGS_HUB_URL_PREFIX locally without NGINX: + +1. No configuration needed (defaults to `/safe-settings`) +2. Build UI: `cd ui && npm run build && cd ..` +3. Start app: `npm run dev` +4. Access at: `http://localhost:3000/safe-settings/dashboard` + +To test a custom SAFE_SETTINGS_HUB_URL_PREFIX: + +1. Set `SAFE_SETTINGS_HUB_URL_PREFIX=/your-path` in `.env` +2. Rebuild UI: `cd ui && npm run build && cd ..` +3. Start app: `npm run dev` +4. Access at: `http://localhost:3000/your-path/dashboard` + +## Troubleshooting + +### Assets not loading +- Make sure you rebuilt the UI after changing SAFE_SETTINGS_HUB_URL_PREFIX +- Check browser console for 404 errors +- Verify NGINX is correctly proxying all paths under the URL prefix + +### API calls failing +- Ensure your proxy passes the full path including SAFE_SETTINGS_HUB_URL_PREFIX +- Check that relative API URLs are being used (not absolute URLs) + +### Navigation broken +- Verify all `` tags use `withBasePath()` utility +- Check that `pathname` comparisons account for the URL prefix + +## Deploying at Root Path + +To deploy at root path instead of the default `/safe-settings`: + +1. Set `SAFE_SETTINGS_HUB_URL_PREFIX=` (empty string) in `.env` +2. Rebuild UI: `cd ui && npm run build && cd ..` +3. Restart application +4. Access at: `http://localhost:3000/dashboard` diff --git a/docs/hubSyncHandler/README.md b/docs/hubSyncHandler/README.md new file mode 100644 index 000000000..ab772122d --- /dev/null +++ b/docs/hubSyncHandler/README.md @@ -0,0 +1,330 @@ +# Safe Settings Organization Sync & Dashboard + + This feature provides a centralized approach to managing the Safe-Settings Admin Repo, allowing Safe-Settings configurations to be sync'd across multiple ORGs. + +## Overview + +This adds the **hub‑and‑spoke synchronization capability** to Safe Settings. + +One central **master admin repository** (the hub) serves as the authoritative source of configuration ('Master' Admin Config Repo) which is automatically propagated to each organization’s **admin repository** (the spokes). + +**Note:** When something changes in the 'Master' repo (the hub), only those changed files are copied to each affected ORG’s admin repo, so everything stays in sync. + +#### :warning: In order for `Safe-Settings Hub-Sync` to enforce Policy sync in the controlled ORGs/Repos, **Safe-Settings** needs to be given **Bypass authority** for any branch protection Ruleset that prevents 'direct pushes'. + +## Sync Lifecycle (High Level) + +```mermaid +graph TD +A0(PR Closed Event) --> A1(HUB Admin Repo) +A1(HUB Admin Repo) --> B(ORG Admin Repo) +A1(HUB Admin Repo) --> C(ORG Admin Repo) +A1(HUB Admin Repo) --> D(ORG Admin Repo) +``` + +## Initial Setup + +Before getting started with the hub-sync configuration, you'll need to deploy and build the Safe Settings App, including the UI. + +### Deploy the Application + +To deploy the Safe Settings application to a hosting environment, choose one of the following deployment options: + +#### Option 1: Virtual Machine (VM) +Deploy Safe Settings on a VM (AWS EC2, Azure VM, Google Compute Engine, etc.): + +1. Set up a VM with Node.js installed +2. Clone the Safe Settings repository to the VM +3. Copy your built UI files to the VM +4. Configure environment variables (see [Environment Variables](#environment-variables--inputs-specific-to-the-hub-sync-feature)) +5. Start the application: + ```bash + npm start + ``` + + :warning: Follow the standard GitHub Probot App [deployment steps](https://probot.github.io/docs/deployment/) + +#### Option 2: Container Deployment +Deploy using Docker: + +1. Use the provided `Dockerfile` or `docker-compose.yml` +2. Build and run the container: + ```bash + docker-compose up -d + ``` + +#### Option 3: Serverless Deployment +Deploy to AWS Lambda or similar serverless platforms using the provided `serverless.yml` configuration. See [AWS deployment documentation](../AWS-README.md) for details. + +#### Option 4: Kubernetes +Deploy to a Kubernetes cluster using the provided Helm charts in the `helm/safe-settings/` directory. + +**Note:** Ensure your deployment environment has network access to GitHub APIs and can receive webhook events from GitHub. + + +### Build the UI + +The Safe Settings dashboard includes a Next.js-based UI that must be compiled before deployment. + +1. Navigate to the UI directory: + ```bash + cd safe-settings/ui/ + ``` + +2. Install dependencies (if not already done): + ```bash + npm install + ``` + +3. Build the Next.js application: + ```bash + npm run build + ``` + +This creates an optimized production build of the dashboard UI that will be served by the Safe Settings application. + +--- + +## Gettings Started + +>**Note:** for the standard setup lets assume that Safe-Settings configuration on the Admin Config Repos (Spokes) are stored in `.github/` + +These are the basic steps to setup the Enterprise-Level Safe-Settings, using **Hub-sync** support. + +### ✅ Step 1: Register the App +**Register the Safe-Settings App** in your Enterprise (Enterprise App) or in your Organization. + +For App "installation tragets" (Where can this GitHub App be installed?) +Choose ***Any account*** + +### ✅ Step 2: Install the App +**Install the Safe-Settings App** in any Organzation that you would like Safe-Settings to manage. + +### ✅ Step 3: Create the 'Org-Level' Safe-Settings Admin Config Repo (Spokes) +Create the Org-Level Repo that is your dedicated Safe-Settings Config Repo and will hold all Safe-Settings configurations for the Org. + +### ✅ Step 4: Create the 'Master' Safe-Settings Admin Config Repo (Hub) +Choose any Organization where the Safe-Settings App is installed and create a 'Master' Safe-Settings Admin Config Repo. + +The Repository requires a standard directory structure for storing the config data: +```bash +.github/ +└─ safe-settings/ + ├── globals/ + │ └── manifest.yml + └── organizations/ + ├── org1/ + │ └── ...yml + └── org2/ + └── ...yml +``` + +Notes: +- The `manifest.yml` is a required file, that defines rules for syncing **Global** Safe-Settings configurations. We will address the content format later. +- `org1` and `org2` are just examples and should be replaced with the real names of the Orgs that you want to manage with the **Hub-Sync**. + +### ✅ Step 5: Configure the 'Master' Safe-Settings Admin Config Repo (Hub) + +The **Hub-Sync** feature supports two options +1. **Organization Sync:** +Any settings file in the `organizations/` directory will be synced to the specific `` (Spoke) Admin config Repo subfolder (eg.: /.github/). Only updated files are sync'd to the ORG admin config Repo (spokes). +1. **Global Sync:** Any settings file in the `globals/` directory will be synced to the specific `` (Spoke) Admin config Repo subfolder (eg.: /.github/). + + :warning: The actual sync operation is based on the rules defined in the `globals/manifest.yml`. The rules provide fine grained control over the sync targets and sync strategy. + +These two options only require that you provide the files you would like to sync, in the correct sub-directory. + +#### ✅ Step 5.1: Configure the `manifest.yml` in the 'Master' Safe-Settings Admin Config Repo (Hub) + +The `manifest.yml` defines the sync rules for global settings distribution. +- Sample `manifest.yml` + + ``` + rules: + - name: global-defaults + # specify the target ORG(s) + targets: + - "*" + files: + - "*.yml" + + # mergeStrategy: merge | overwrite | preserve + # -------------------------------------------- + # merge = use a PR to sync files + # overwrite = sync all files to the target ORG(s) (no PR) + mergeStrategy: merge + + - name: security-policies + # specify the target ORG(s) + targets: + - "acme-*" + - "foo-bar" + files: + - settings.yml + mergeStrategy: overwrite + + # optional toggle, default true + # enabled: false + ``` + +### Example Rule Breakdown + +```yaml +- name: global-defaults + targets: + - "*" + files: + - "*.yml" + mergeStrategy: merge +``` +- **Purpose:** Sync all YAML files to all organizations, merging changes via PR. + +```yaml +- name: security-policies + targets: + - "acme-*" + - "foo-bar" + files: + - settings.yml + mergeStrategy: overwrite + enabled: false +``` + +- **Purpose:** Overwrite `settings.yml` in specific organizations, but currently disabled. + + +### `manifest.yml` Reference + +The `manifest.yml` file defines synchronization rules for Safe-Settings hub-and-spoke configuration management. Each rule specifies which organizations and files to target, and how to handle synchronization. + +### Top-Level Structure + +```yaml +rules: + - name: + targets: [, ...] + files: [, ...] + mergeStrategy: + enabled: # optional + # ...additional fields as needed +``` + +--- + +### Elements + +#### `rules` +- **Type:** Array of objects +- **Description:** List of synchronization rules. Each rule controls how specific files are synced to target organizations. + +#### Rule Object + +##### `name` +- **Type:** String +- **Description:** Unique identifier for the rule. Used for reference and logging. +- **Example:** `global-defaults`, `security-policies` + +##### `targets` +- **Type:** Array of strings +- **Description:** List of organization names or patterns to apply the rule to. + - `"*"`: All organizations + - `"acme-*"`: Organizations with names starting with `acme-` + - `"foo-bar"`: Specific organization +- **Example:** + ```yaml + targets: + - "*" + - "acme-*" + - "foo-bar" + ``` + +##### `files` +- **Type:** Array of strings +- **Description:** File patterns to sync. Supports wildcards. + - `"*.yml"`: All YAML files + - `"settings.yml"`: Specific file +- **Example:** + ```yaml + files: + - "*.yml" + - "settings.yml" + ``` + +##### `mergeStrategy` +- **Type:** String (`merge`, `overwrite`, `preserve`) +- **Description:** Determines how files are synced: + - `merge`: use a PR to sync files + - `overwrite`: Sync all files, replacing existing ones (direct commit, no PR) +- **Example:** + ```yaml + mergeStrategy: merge + ``` + +##### `enabled` +- **Type:** Boolean (optional) +- **Description:** Toggle to enable or disable the rule. Default is `true`. +- **Example:** + ```yaml + enabled: false + ``` + +--- + +### Environment Variables & Inputs Specific to the **Hub-Sync** feature + +| Name | Purpose | Default | +|------|---------|---------| +| `SAFE_SETTINGS_HUB_REPO` | Repo for master safe-settings contents | admin-master | +| `SAFE_SETTINGS_HUB_ORG` | Organization that hold the Repo | admin-master-org | +| `SAFE_SETTINGS_HUB_PATH` | source folder | .github/safe-settings | +| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Use a PR or direct commit | false | + + +--- +--- + +## Hub Sync Scenarios + +1. Sync the `Hub Admin Repo` changes to a `Safe-Settings Admin Repo` in **the same ORG** as the Hub Admin Repo. + +2. Sync the `Hub Admin changes` to a `Safe-Settings Admin Repo` in **a different ORG**. + +3. _`'Global'`_ `Hub Admin Repo` updates. +Changes will `applied to all Organization` + +--- + +## Safe-Settings Hub API endpoints + +### API Endpoints + +The following table summarizes the Safe Settings API endpoints: + +| Endpoint | Method | Purpose | Example Usage | +|------------------------------------------|--------|------------------------------------------------------|---------------| +| `/api/safe-settings/installation` | GET | Organization installation, repo, and sync status | Fetch org status | +| `/api/safe-settings/hub/content` | GET | List hub repo files/directories | List hub files | +| `/api/safe-settings/hub/content/*` | GET | Fetch specific file or directory from hub repo | Get file content | +| `/api/safe-settings/hub/import` | POST | Import settings from orgs into the hub | Import org settings | +| `/api/safe-settings/env` | GET | App environment/config variables | Get env vars | + +**Examples:** +- Fetch org installation status: + ```http + GET /api/safe-settings/installation + ``` +- Import settings from orgs: + ```http + POST /api/safe-settings/hub/import + Body: { "orgs": ["org1", "org2"] } + ``` +- List hub repo files: + ```http + GET /api/safe-settings/hub/content?ref=main&recursive=true + ``` +- Get environment variables: + ```http + GET /api/safe-settings/env + ``` + +--- \ No newline at end of file diff --git a/docs/hubSyncHandler/architecture-sequence.md b/docs/hubSyncHandler/architecture-sequence.md new file mode 100644 index 000000000..1fc39f447 --- /dev/null +++ b/docs/hubSyncHandler/architecture-sequence.md @@ -0,0 +1,222 @@ +# HubSyncHandler Architecture - Sequence Diagram + +This document provides a detailed sequence diagram showing the flow of the Hub Sync Handler, which synchronizes safe-settings configurations between a centralized hub repository and organization-specific admin repositories. + +## Main Flow + +```mermaid +sequenceDiagram + participant GH as GitHub Event + participant Index as index.js + participant HSH as hubSyncHandler + participant SGU as syncHubGlobalsUpdate + participant SOU as syncHubOrgUpdate + participant HubRepo as Hub Repository + participant OrgRepo as Org Admin Repo + + GH->>Index: pull_request.closed event + Index->>HSH: hubSyncHandler(robot, context) + + HSH->>HSH: Validate event source + Note over HSH: Check if from SAFE_SETTINGS_HUB_ORG/SAFE_SETTINGS_HUB_REPO + + alt Not from hub repo/org + HSH->>Index: Return (ignore event) + end + + HSH->>GH: Get PR changed files + GH-->>HSH: List of changed files + + HSH->>HSH: Analyze changed files + + par Parallel Processing + alt Files in globals/ folder + HSH->>SGU: syncHubGlobalsUpdate(robot, context, files) + SGU->>HubRepo: Load manifest.yml + HubRepo-->>SGU: Manifest rules + + loop For each changed global file + SGU->>SGU: Match file against manifest patterns + + loop For each matching rule + SGU->>SGU: Determine target orgs + Note over SGU: Use rule.orgs or all installations + + loop For each target org + SGU->>OrgRepo: Get installation + + alt Direct Push Mode + SGU->>OrgRepo: Push to main branch + else PR Mode + SGU->>OrgRepo: Create branch + SGU->>HubRepo: Get file content + SGU->>OrgRepo: Commit file + SGU->>OrgRepo: Create PR + end + end + end + end + end + + alt Files in organizations// folder + HSH->>SOU: syncHubOrgUpdate(robot, context, orgName, destRepo, destFolder) + + SOU->>SOU: Extract org name from file path + SOU->>OrgRepo: Get org installation + + alt Installation not found + SOU->>HSH: Log warning and return + end + + SOU->>HubRepo: Get changed files for org + HubRepo-->>SOU: List of files + + alt Direct Push Mode + SOU->>OrgRepo: Use main branch + else PR Mode + SOU->>OrgRepo: Create sync branch + Note over SOU: safe-settings-sync/pr-{num}-{org}-{timestamp} + end + + loop For each changed file + SOU->>HubRepo: Get file content from PR head + HubRepo-->>SOU: File content + + SOU->>OrgRepo: Check if file exists + OrgRepo-->>SOU: Existing SHA (or 404) + + SOU->>OrgRepo: Create or update file + Note over SOU: Commit with Safe Settings Bot identity + end + + alt Direct Push Mode + SOU->>HSH: Log direct push complete + else PR Mode + SOU->>OrgRepo: Create PR + Note over SOU: Title: Sync safe-settings from hub PR #X + OrgRepo-->>SOU: PR URL + end + end + end + + HSH->>Index: Complete +``` + +## Reverse Flow: Import Settings from Orgs to Hub + +```mermaid +sequenceDiagram + participant API as API/Route Handler + participant RSO as retrieveSettingsFromOrgs + participant OrgRepo as Org Admin Repo + participant HubRepo as Hub Repository + + API->>RSO: retrieveSettingsFromOrgs(robot, orgNames, options) + + RSO->>RSO: Get all installations + RSO->>HubRepo: Get base branch ref (main) + HubRepo-->>RSO: Base SHA + + loop For each org in orgNames + RSO->>HubRepo: Check if org already exists + Note over RSO: Path: CONFIG_PATH/SAFE_SETTINGS_HUB_PATH/organizations/ + + alt Org already imported + RSO->>RSO: Skip org (already_imported) + else Org not found in hub + RSO->>RSO: Get org installation + + alt Installation not found + RSO->>RSO: Record error and continue + end + + RSO->>OrgRepo: Collect all files recursively + Note over RSO: From CONFIG_PATH in ADMIN_REPO + + alt Admin repo not found + RSO->>RSO: Record N/A status + else Files collected + OrgRepo-->>RSO: List of files with content + + RSO->>HubRepo: Create import branch + Note over RSO: safe-settings-import// + + loop For each file + RSO->>HubRepo: Get existing file SHA (if exists) + RSO->>HubRepo: Create or update file + Note over RSO: Destination: organizations// + end + + RSO->>HubRepo: Create PR + Note over RSO: Title: Import safe-settings from + HubRepo-->>RSO: PR URL + + RSO->>RSO: Record import success + end + end + end + + RSO-->>API: Return results array + Note over RSO: [{org, status, reason|error}, ...] +``` + +## Key Decision Points + +### Event Validation +```mermaid +flowchart TD + A[PR Closed Event] --> B{From Hub Org?} + B -->|No| C[Ignore Event] + B -->|Yes| D{From Hub Repo?} + D -->|No| C + D -->|Yes| E[Process Event] +``` + +### File Routing Logic +```mermaid +flowchart TD + A[Get Changed Files] --> B{Check File Paths} + B --> C{Contains /globals/?} + C -->|Yes| D[syncHubGlobalsUpdate] + B --> E{Contains /organizations/?} + E -->|Yes| F[Extract Org Names] + F --> G[syncHubOrgUpdate for each org] + C -->|No| H[No Action] + E -->|No| H +``` + +### Sync Mode Decision +```mermaid +flowchart TD + A[Ready to Sync] --> B{SAFE_SETTINGS_HUB_DIRECT_PUSH?} + B -->|true| C[Push directly to main] + B -->|false| D[Create sync branch] + D --> E[Commit changes] + E --> F[Create PR] +``` + +## Environment Variables + +| Variable | Purpose | Used In | +|----------|---------|---------| +| `SAFE_SETTINGS_HUB_ORG` | Hub organization name | Event validation | +| `SAFE_SETTINGS_HUB_REPO` | Hub repository name | Event validation | +| `SAFE_SETTINGS_HUB_PATH` | Base path in hub (e.g., "safe-settings") | File path resolution | +| `ADMIN_REPO` | Target repo name in orgs | Destination repo | +| `CONFIG_PATH` | Config folder (e.g., ".github") | File path resolution | +| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Push mode ("true"/"false") | Branch vs direct push | + +## Error Handling + +All functions implement try-catch blocks with logging: +- **Installation errors**: Log warning and skip org +- **File read errors**: Log error and continue with next file +- **PR creation errors**: Log error and throw (stops sync for that org) +- **Repository not found**: Record as "N/A" status and continue + +## Notes + +- **Parallel Processing**: `syncHubGlobalsUpdate` and `syncHubOrgUpdate` can run in parallel if both globals and organizations folders have changes +- **Authentication**: Each org requires a separate authenticated octokit client via `robot.auth(installationId)` +- **File Logging**: All operations are logged to `hubSyncHandler.log` (configurable) +- **Idempotency**: Functions check for existing branches/PRs before creating new ones diff --git a/docs/sample-settings/sample-deployment-settings.yml b/docs/sample-settings/sample-deployment-settings.yml index 6164d4389..f3c6e8b37 100644 --- a/docs/sample-settings/sample-deployment-settings.yml +++ b/docs/sample-settings/sample-deployment-settings.yml @@ -38,3 +38,14 @@ overridevalidators: Some error script: | return true + +# disable_plugins (optional) — disable safe-settings plugins at the deployment layer. +# Each entry is either a plugin name (shorthand for target: all) or { plugin, target }. +# target is one of: self | children | all (default: all). +# Declared here, target: all strips the plugin from every level below for every repo. +# See docs/README.md ("Disabling plugins") for the full strip matrix and limitations. +# +# disable_plugins: +# - plugin: rulesets # disables rulesets everywhere +# target: all +# - milestones # shorthand → { plugin: milestones, target: all } diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 7e19d3354..e712d5c5f 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -237,18 +237,18 @@ rulesets: # The actors that can bypass the rules in this ruleset bypass_actors: - - actor_id: number - # type: The type of actor that can bypass a ruleset - # - RepositoryRole - # - Team - # - Integration - # - OrganizationAdmin - actor_type: Team - # When the specified actor can bypass the ruleset. `pull_request` - # means that an actor can only bypass rules on pull requests. - # - always - # - pull_request - bypass_mode: pull_request + # - actor_id: number + # # type: The type of actor that can bypass a ruleset + # - RepositoryRole + # - Team + # - Integration + # - OrganizationAdmin + # actor_type: Team + # # When the specified actor can bypass the ruleset. `pull_request` + # # means that an actor can only bypass rules on pull requests. + # - always + # - pull_request + # bypass_mode: pull_request - actor_id: 1 actor_type: OrganizationAdmin @@ -262,6 +262,23 @@ rulesets: actor_type: Integration bypass_mode: always + # Instead of looking up numeric ids, you can use the `name` field to + # reference an actor by name. safe-settings resolves it to `actor_id` + # based on `actor_type` before applying the ruleset: + # - Team -> team slug + # - User -> username + # - Integration -> GitHub App slug + # - RepositoryRole -> role name (built-in: read, triage, write, + # maintain, admin; or a custom role name) + # Provide either `name` or `actor_id`, not both. + - name: my-team + actor_type: Team + bypass_mode: always + + - name: admin + actor_type: RepositoryRole + bypass_mode: always + conditions: # Parameters for a repository ruleset ref name condition ref_name: @@ -322,6 +339,17 @@ rulesets: # All conversations on code must be resolved before a pull # request can be merged. required_review_thread_resolution: true + # A collection of reviewers and the file patterns they must + # approve. Each reviewer is a team. Use `id` (team id) or, to + # avoid looking up the id, `slug` (team slug) which + # safe-settings resolves before applying the ruleset. Provide + # either `slug` or `id`, not both. + required_reviewers: + - minimum_approvals: 1 + file_patterns: ["*.js"] + reviewer: + slug: my-reviewers-team + type: Team # Choose which status checks must pass before branches can be merged # into a branch that matches this rule. When enabled, commits must @@ -397,3 +425,34 @@ rulesets: negate: false operator: regex pattern: ".*\/.*" + +# disable_plugins (optional) — disable safe-settings plugins at the org layer. +# Declared here: +# - target: self → strips from the org layer only (affects org-level runs: +# rulesets, custom_repository_roles). +# - target: children → strips from suborg + repo layers (per-repo runs). +# - target: all → strips from org + suborg + repo layers. +# Lower levels can never undo a strip declared at a higher level (union-only cascade). +# See docs/README.md ("Disabling plugins") for the full strip matrix. +# +# disable_plugins: +# - plugin: custom_repository_roles +# target: self +# - branches # shorthand → { plugin: branches, target: all } + +# additive_plugins (optional) — run selected Diffable plugins in additive mode. +# In additive mode a plugin will only add and update entries; it will never +# call remove(). Items that exist on GitHub but are absent from the YAML are +# preserved. This is useful when you want safe-settings to enforce a baseline +# of settings while still allowing teams to manage their own extra labels, +# teams, environments, etc. +# +# Supported plugins (must extend Diffable): +# labels, collaborators, teams, milestones, autolinks, environments, +# custom_properties, variables, rulesets, custom_repository_roles +# +# NOT supported (non-Diffable): repository, archive, branches, validator +# +# additive_plugins: +# - labels # never delete labels not in YAML +# - collaborators # never remove collaborators not in YAML diff --git a/docs/sample-settings/suborg.yml b/docs/sample-settings/suborg.yml index a509847cc..42e822993 100644 --- a/docs/sample-settings/suborg.yml +++ b/docs/sample-settings/suborg.yml @@ -14,3 +14,15 @@ suborgproperties: - EDP: true # Every other property is the same as the org level settings and can be overridden here + +# disable_plugins (optional) — disable safe-settings plugins for repos matched +# by this suborg. Declared here, target values mean: +# - self → strip from the suborg layer only. +# - children → strip from the repo layer for matched repos. +# - all → strip from suborg + repo layers for matched repos. +# Note: a suborg-level disable cannot strip config defined at the org layer. +# See docs/README.md ("Disabling plugins") for details. +# +# disable_plugins: +# - plugin: labels +# target: all diff --git a/docs/whitepaper-deploying-policies-at-scale.md b/docs/whitepaper-deploying-policies-at-scale.md new file mode 100644 index 000000000..406236b7a --- /dev/null +++ b/docs/whitepaper-deploying-policies-at-scale.md @@ -0,0 +1,936 @@ +# Deploying Policies at Scale Across Organizations Using GitHub Safe-Settings + +## A White Paper on Policy-as-Code for GitHub Enterprise Governance + +--- + +**Version:** 1.0 +**Date:** May 2026 +**Author:** GitHub Safe-Settings Team + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [The Challenge: Governing Repositories at Scale](#the-challenge-governing-repositories-at-scale) +3. [Introducing Safe-Settings: Policy-as-Code for GitHub](#introducing-safe-settings-policy-as-code-for-github) +4. [Architecture Overview](#architecture-overview) +5. [The Configuration Hierarchy](#the-configuration-hierarchy) +6. [Designing Your Policy Framework](#designing-your-policy-framework) +7. [Deployment Models](#deployment-models) +8. [Scaling Strategies](#scaling-strategies) +9. [Governance Workflows](#governance-workflows) +10. [Advanced Policy Controls](#advanced-policy-controls) +11. [Drift Detection and Remediation](#drift-detection-and-remediation) +12. [Multi-Organization Deployments](#multi-organization-deployments) +13. [Security Considerations](#security-considerations) +14. [Case Study: Enterprise Rollout](#case-study-enterprise-rollout) +15. [Best Practices](#best-practices) +16. [Conclusion](#conclusion) + +--- + +## Executive Summary + +As organizations scale their software delivery practices on GitHub, managing repository configurations consistently across hundreds or thousands of repositories becomes a critical governance challenge. Manual configuration is error-prone, difficult to audit, and impossible to enforce at scale. + +**GitHub Safe-Settings** provides a policy-as-code solution that enables organizations to centrally define, enforce, and audit repository settings across an entire GitHub organization. By storing configuration as YAML in a centralized admin repository, Safe-Settings brings the principles of Infrastructure-as-Code to GitHub governance — enabling version control, peer review, automated validation, and continuous enforcement of organizational policies. + +This white paper provides a comprehensive guide to deploying Safe-Settings at enterprise scale, covering architecture decisions, policy design patterns, scaling strategies, and operational best practices. + +--- + +## The Challenge: Governing Repositories at Scale + +### The Problem + +Enterprise organizations on GitHub commonly face these governance challenges: + +- **Configuration sprawl**: Thousands of repositories with inconsistent settings — varying branch protections, team permissions, security configurations, and compliance controls. +- **Manual drift**: Repository administrators making ad-hoc changes that deviate from organizational standards, often without audit trails. +- **Onboarding delays**: New repositories require manual setup of branch protections, team access, labels, and compliance configurations. +- **Audit burden**: Demonstrating compliance with internal security policies or regulatory requirements (SOC 2, FedRAMP, HIPAA) demands evidence that every repository meets baseline standards. +- **Decentralized ownership**: Different teams need autonomy to manage their project-specific settings while still adhering to organization-wide baselines. + +### Why Existing Approaches Fall Short + +| Approach | Limitation | +|----------|-----------| +| **Manual configuration** | Does not scale; no audit trail; prone to drift | +| **GitHub repository templates** | Only applies at creation time; no ongoing enforcement | +| **Custom scripts/APIs** | High maintenance; fragile; no built-in review workflow | +| **Per-repo settings files** | Settings files live in individual repos, meaning any contributor can bypass policies | + +Safe-Settings addresses all of these limitations by centralizing policy definitions in a protected admin repository, enforcing them continuously, and providing a pull request-based review workflow for all changes. + +--- + +## Introducing Safe-Settings: Policy-as-Code for GitHub + +Safe-Settings is a GitHub App built on the [Probot](https://probot.github.io/) framework that implements policy-as-code for GitHub organizations. It operates on three foundational principles: + +### 1. Centralized Configuration + +All settings are stored in a single `admin` repository (configurable via the `ADMIN_REPO` environment variable). Unlike per-repo settings files, this prevents repository maintainers from overriding organizational policies. + +### 2. Hierarchical Policy Model + +Settings are defined at three levels with a clear precedence order: + +``` +Organization (baseline) → Sub-Organization (team/project overrides) → Repository (specific exceptions) +``` + +Higher-specificity levels override lower ones, enabling a flexible yet governed configuration model. + +### 3. Continuous Enforcement + +Safe-Settings responds to webhook events in real-time and can run on a configurable schedule (via cron) to detect and remediate configuration drift — ensuring that manual changes are automatically reverted to the declared policy state. + +### What Can Be Managed + +Safe-Settings supports a comprehensive set of GitHub configurations: + +| Category | Capabilities | +|----------|-------------| +| **Repository Settings** | Visibility, description, homepage, merge strategies, wiki, issues, projects, default branch, auto-init, security settings | +| **Branch Protections** | Required reviews, status checks, admin enforcement, push restrictions, dismiss stale reviews, code owner reviews | +| **Rulesets** | Organization and repository-level rulesets with branch/tag targeting, bypass actors, pattern rules, required workflows | +| **Teams & Collaborators** | Team permissions, collaborator access with include/exclude patterns | +| **Labels & Milestones** | Standardized issue labels and milestone definitions | +| **Custom Properties** | Organization-defined custom property values for repositories | +| **Environments** | Deployment environments with protection rules, wait timers, reviewers, and environment variables | +| **Autolinks** | External reference linking (e.g., Jira ticket prefixes) | +| **Repository Naming** | Regex-based validation of repository names | +| **Custom Repository Roles** | Organization-level custom roles | +| **Variables** | Repository and environment variables | + +--- + +## Architecture Overview + +### System Components + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GitHub Platform │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ Webhooks │ │ Admin Repo │ │ Target Repositories │ │ +│ │ (Events) │ │ (Policies) │ │ (1000s of repos) │ │ +│ └─────┬─────┘ └──────┬───────┘ └──────────┬───────────┘ │ +│ │ │ │ │ +└────────┼────────────────┼──────────────────────┼────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Safe-Settings App │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │ +│ │ Event Handler │ │ Config Merger │ │ Plugin Engine │ │ +│ │ (Webhooks) │ │ (Hierarchy) │ │ (API Calls) │ │ +│ └──────────────┘ └───────────────┘ └──────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │ +│ │ Drift Detect │ │ Validators │ │ Diff Engine │ │ +│ │ (Cron Sync) │ │ (Rules) │ │ (Smart Compare) │ │ +│ └──────────────┘ └───────────────┘ └──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Event Processing Flow + +Safe-Settings listens to the following webhook events and responds accordingly: + +| Event | Action | +|-------|--------| +| `push` to admin repo (default branch) | Apply changed settings to affected repositories | +| `repository.created` | Apply full policy stack (org → suborg → repo) to the new repository | +| `repository.edited` | Sync settings to prevent unauthorized changes | +| `repository.renamed` | Optionally block human-initiated renames; sync config files for bot-initiated renames | +| `branch_protection_rule` | Revert unauthorized branch protection changes | +| `repository_ruleset` | Revert unauthorized ruleset modifications | +| `member` / `team` changes | Revert unauthorized collaborator or team permission changes | +| `custom_property_values` | Re-evaluate suborg membership and apply matching policies | +| `pull_request` (to admin repo) | Run in dry-run/NOP mode and report validation results as check runs | + +### Smart Diff Engine + +Safe-Settings does not blindly apply configuration on every event. It performs an intelligent comparison between the declared policy and the current GitHub state, generating a precise diff of `additions`, `modifications`, and `deletions`. API calls are made only when real changes exist, which is critical for performance at scale. + +--- + +## The Configuration Hierarchy + +### Directory Structure + +All policy files reside in the admin repository under the `.github` directory: + +``` +admin-repo/ +├── .github/ +│ ├── settings.yml # Organization-wide baseline policies +│ ├── suborgs/ # Sub-organization policies +│ │ ├── platform-team.yml # Policies for platform team repos +│ │ ├── frontend-team.yml # Policies for frontend team repos +│ │ ├── compliance-critical.yml # Policies for compliance-critical repos +│ │ └── open-source.yml # Policies for open-source repos +│ └── repos/ # Repository-specific overrides +│ ├── api-gateway.yml # Specific settings for api-gateway +│ ├── auth-service.yml # Specific settings for auth-service +│ └── docs-site.yml # Specific settings for docs-site +├── CODEOWNERS # Governs who can approve policy changes +└── deployment-settings.yml # Runtime configuration for the app +``` + +### Precedence Order + +``` +Repository-specific > Sub-Organization > Organization +``` + +When Safe-Settings computes the effective configuration for a given repository, it: + +1. Starts with the **organization-level** settings from `settings.yml` +2. Overlays any matching **sub-organization** settings +3. Overlays any **repository-specific** settings + +This layered approach means that organization-wide baselines are always applied, but teams can customize settings within the bounds defined by validators. + +### Sub-Organization Membership + +Sub-organizations ("suborgs") are a powerful abstraction for grouping repositories. A repository can belong to a suborg based on three criteria: + +| Criterion | Configuration Key | Example | +|-----------|------------------|---------| +| **Repository name pattern** | `suborgrepos` | `suborgrepos: ["api-*", "service-*"]` | +| **Team membership** | `suborgteams` | `suborgteams: ["platform-core"]` | +| **Custom property values** | `suborgproperties` | `suborgproperties: [{"compliance": "sox"}]` | + +This flexibility enables policies to be applied based on organizational structure, project taxonomy, or compliance classification — all without hard-coding repository lists. + +--- + +## Designing Your Policy Framework + +### Step 1: Define Your Organization Baseline + +> **⚠️ Scaling Best Practice: Keep `settings.yml` Minimal** +> +> Any change to the org-level `settings.yml` triggers Safe-Settings to process **every managed repository** in the organization. For orgs with thousands of repos, this can cascade into thousands of API calls and risk breaching GitHub's API rate limits within the 1-hour token lifetime. +> +> **Recommended approach:** Limit `settings.yml` to resources that are applied at the **org level** — specifically **org-level rulesets** and **custom repository roles**. These are managed via org-scoped API endpoints and do **not** require per-repo API calls. +> +> Move repo-scoped settings (repository configuration, teams, collaborators, labels, branch protections, etc.) to **suborg-level** files. This way, changes only affect the subset of repos matched by each suborg, keeping API call volume manageable and predictable. + +Define your organization-wide baseline using org-level rulesets and custom roles: + +```yaml +# .github/settings.yml — Organization baseline +# Keep this file minimal: only org-level rulesets and custom roles. +# Repo-scoped settings (teams, labels, repository config) belong in suborgs. + +rulesets: + - name: Branch Protection + target: branch + enforcement: active + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: always + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + repository_name: + include: ["~ALL"] + exclude: [] + rules: + - type: pull_request + parameters: + dismiss_stale_reviews_on_push: true + require_code_owner_review: true + require_last_push_approval: true + required_approving_review_count: 1 + required_review_thread_resolution: true + - type: required_status_checks + parameters: + strict_required_status_checks_policy: true + required_status_checks: [] + + - name: Branch Integrity + target: branch + enforcement: active + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: always + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + repository_name: + include: ["~ALL"] + exclude: [] + rules: + - type: deletion + - type: non_fast_forward + - type: required_linear_history + - type: required_signatures +``` + +Then define repo-scoped baseline settings at the **suborg level** to avoid cascading org-wide API calls. Use a broad suborg definition (e.g., `~ALL` repos or a wildcard pattern) to achieve org-wide coverage without the scaling risks: + +```yaml +# .github/suborgs/baseline.yml — Default repo-scoped settings for all repos +# Changes here only trigger API calls for matched repos, not the entire org. + +suborgrepos: + - "*" + +repository: + private: true + allow_auto_merge: false + delete_branch_on_merge: true + allow_update_branch: true + security: + enableVulnerabilityAlerts: true + enableAutomatedSecurityFixes: true + +teams: + - name: security-team + permission: admin + - name: all-engineers + permission: push + +labels: + - name: bug + color: "d73a4a" + description: "Something isn't working" + - name: security + color: "e11d48" + description: "Security-related issue" + - name: compliance + color: "7c3aed" + description: "Compliance-related" + +validator: + pattern: "[a-z0-9]+(-[a-z0-9]+)*" +``` + +### Step 2: Define Sub-Organization Policies + +Create suborg files for teams or projects that need additional or different policies: + +```yaml +# .github/suborgs/compliance-critical.yml + +# Repos with the "compliance" custom property set to "sox" +suborgproperties: + - compliance: sox + +# Stricter branch protections for SOX-compliant repositories +branches: + - name: default + protection: + required_pull_request_reviews: + required_approving_review_count: 2 + dismiss_stale_reviews: true + require_code_owner_reviews: true + require_last_push_approval: true + enforce_admins: true + +# Additional team access for compliance repos +teams: + - name: compliance-auditors + permission: pull +``` + +```yaml +# .github/suborgs/open-source.yml + +suborgrepos: + - "oss-*" + +repository: + private: false + visibility: public + has_wiki: true + +# Public repos need different branch protections +branches: + - name: default + protection: + required_pull_request_reviews: + required_approving_review_count: 2 + require_code_owner_reviews: true +``` + +### Step 3: Define Repository-Specific Overrides + +For repositories that need unique configurations: + +```yaml +# .github/repos/api-gateway.yml + +repository: + description: "Central API gateway for all microservices" + homepage: "https://api-docs.example.com" + topics: + - api + - gateway + - critical-infrastructure + +branches: + - name: default + protection: + required_status_checks: + strict: true + contexts: + - "ci/build" + - "ci/integration-tests" + - "security/codeql" + +environments: + - name: production + wait_timer: 30 + prevent_self_review: true + reviewers: + - type: Team + id: 12345 # platform-leads team + deployment_branch_policy: + protected_branches: true + custom_branch_policies: false +``` + +--- + +## Deployment Models + +Safe-Settings supports multiple deployment architectures to fit your infrastructure requirements. + +### Docker (Recommended for Most Organizations) + +Best for organizations with existing container infrastructure. + +```bash +# Build and run +docker build -t safe-settings . +docker run -d -p 3000:3000 --env-file .env safe-settings + +# Or with docker-compose +docker-compose --env-file .env up -d +``` + +**Advantages:** Simple, portable, works with any container orchestration platform. + +### Kubernetes with Helm + +Best for organizations running Kubernetes clusters. + +```bash +# Install using the official Helm chart +helm install safe-settings \ + oci://ghcr.io/github/helm-charts/safe-settings \ + --values myvalues.yaml +``` + +**Advantages:** Native Kubernetes integration, auto-scaling, rolling updates, health checks, secrets management via Kubernetes Secrets or external secret stores. + +### AWS Lambda (Serverless) + +Best for organizations wanting minimal infrastructure overhead. + +Use the [SafeSettings-Template](https://github.com/bheemreddy181/SafeSettings-Template) for a production-ready deployment featuring: + +- Docker-based Lambda functions +- Dual Lambda architecture (webhook handler + scheduled sync) +- GitHub Actions CI/CD pipeline +- Auto-scaling with pay-per-execution pricing + +### GitHub Actions + +Best for organizations that want to avoid deploying infrastructure entirely. + +Safe-Settings can be run as a GitHub Action, triggered by workflow dispatch or on a schedule. See the [GitHub Actions Guide](github-action.md) for configuration details. + +### Deployment Comparison + +| Model | Scalability | Operational Overhead | Real-Time Events | Scheduled Sync | Cost Model | +|-------|-------------|---------------------|-------------------|----------------|------------| +| **Docker** | Medium | Medium | ✅ Webhooks | ✅ CRON | Fixed | +| **Kubernetes** | High | Medium-High | ✅ Webhooks | ✅ CRON | Fixed | +| **AWS Lambda** | Very High | Low | ✅ Webhooks | ✅ EventBridge | Pay-per-use | +| **GitHub Actions** | Medium | Very Low | ❌ Polling only | ✅ Cron triggers | Actions minutes | + +--- + +## Scaling Strategies + +### Performance Considerations + +When managing thousands of repositories, Safe-Settings employs several strategies to operate within constraints: + +1. **Org-level settings are org-scoped**: Rulesets and custom repository roles defined in `settings.yml` are applied via org-level API endpoints — they do **not** generate per-repo API calls. This is why `settings.yml` should be reserved for these resources only. + +2. **Selective configuration loading**: Only repo-specific YAML files relevant to the changed settings are loaded — not the entire `.github/repos/` directory. Full loading occurs only for global settings changes. + +3. **Smart diff comparisons**: Before making any API call, Safe-Settings compares the desired state with the current GitHub state. API calls are only made when real changes are detected. + +4. **Rate limit handling**: Built on Probot, the app automatically handles GitHub API rate limits and abuse limits with exponential backoff. + +5. **Token lifetime awareness**: GitHub App installation tokens expire after 1 hour. Safe-Settings is designed to complete all work within this window. + +### Configuration for Large Organizations + +For organizations with 1,000+ repositories, consider these configurations: + +```env +# Run scheduled sync during off-peak hours +CRON=0 2 * * * + +# Set appropriate log level for production +LOG_LEVEL=info + +# Enable PR comments for audit trail +ENABLE_PR_COMMENT=true + +# Block manual repo renames to maintain config consistency +BLOCK_REPO_RENAME_BY_HUMAN=true +``` + +### Restricting Scope + +Use `deployment-settings.yml` to control which repositories Safe-Settings manages: + +```yaml +# deployment-settings.yml + +restrictedRepos: + include: + - "service-*" + - "lib-*" + - "infra-*" + exclude: + - admin + - .github + - safe-settings + - "test-*" + - "sandbox-*" +``` + +This is particularly useful during phased rollouts — start with a subset of repositories and expand as confidence grows. + +--- + +## Governance Workflows + +### Pull Request-Based Policy Changes + +All policy changes follow a pull request workflow, providing: + +1. **Version control**: Every change to organizational policies is tracked in Git history. +2. **Peer review**: Changes must be approved before taking effect. +3. **Dry-run validation**: Safe-Settings runs in NOP (no-operation) mode on PRs, producing a detailed report of what would change across all affected repositories. +4. **Check runs**: PR checks pass or fail based on dry-run results, including custom validator outcomes. + +### CODEOWNERS for Policy Governance + +Use GitHub's CODEOWNERS file in the admin repo to establish approval requirements: + +``` +# CODEOWNERS in admin repo + +# Security team must approve all policy changes +.github/settings.yml @org/security-team @org/platform-leads + +# Team leads approve their suborg policies +.github/suborgs/platform-team.yml @org/platform-leads +.github/suborgs/frontend-team.yml @org/frontend-leads +.github/suborgs/compliance-critical.yml @org/compliance-team @org/security-team + +# Repo owners can manage their repo-specific settings +.github/repos/api-gateway.yml @org/api-team +.github/repos/auth-service.yml @org/identity-team + +# Deployment settings require platform admin approval +deployment-settings.yml @org/platform-admins +``` + +This enables **delegated governance**: teams can manage their own settings within the guardrails established by the organization baseline and custom validators. + +### Change Review Workflow + +``` +Developer Admin Repo Safe-Settings GitHub + │ │ │ │ + ├─ Create branch ──►│ │ │ + ├─ Modify YAML ────►│ │ │ + ├─ Open PR ────────►│ │ │ + │ ├─ Webhook ───────────►│ │ + │ │ ├─ Dry-run ──────────┤ + │ │ ├─ Validate rules ───┤ + │ │ ├─ Report results ──►│ + │ │ │ │ + │◄──── Review PR with check results ──────────────────────────-│ + │ │ │ │ + ├─ Merge PR ───────►│ │ │ + │ ├─ Push webhook ──────►│ │ + │ │ ├─ Apply settings ──►│ + │ │ ├─ Create check ────►│ + │ │ │ │ +``` + +--- + +## Advanced Policy Controls + +### Custom Configuration Validators + +Validators allow you to define rules that settings must satisfy before they can be applied. They are defined in `deployment-settings.yml`. + +#### Config Validators + +Validate a setting in isolation: + +```yaml +configvalidators: + # Prevent granting admin access to collaborators + - plugin: collaborators + error: "Admin role cannot be assigned to individual collaborators" + script: | + return baseconfig.permission !== 'admin' + + # Ensure all repos have a description + - plugin: repository + error: "Repository must have a description" + script: | + return baseconfig.description && baseconfig.description.length > 10 + + # Validate repository naming conventions + - plugin: repository + error: "Repository names must follow the pattern: team-project-component" + script: | + const pattern = /^[a-z]+-[a-z]+-[a-z0-9-]+$/ + return pattern.test(baseconfig.name) +``` + +#### Override Validators + +Enforce constraints when lower-level settings override higher-level ones: + +```yaml +overridevalidators: + # Prevent reducing required approvers below the org baseline + - plugin: branches + error: "Cannot reduce required approving review count below organization minimum" + script: | + if (baseconfig.protection.required_pull_request_reviews.required_approving_review_count && + overrideconfig.protection.required_pull_request_reviews.required_approving_review_count) { + return overrideconfig.protection.required_pull_request_reviews.required_approving_review_count >= + baseconfig.protection.required_pull_request_reviews.required_approving_review_count + } + return true + + # Prevent disabling admin enforcement + - plugin: branches + error: "Cannot disable admin enforcement for branch protections" + script: | + if (baseconfig.protection.enforce_admins === true) { + return overrideconfig.protection.enforce_admins !== false + } + return true +``` + +### Disabling Plugins + +For scenarios where certain settings should not be managed by Safe-Settings at specific scopes: + +```yaml +# At the org level — disable milestones management entirely +disable_plugins: + - milestones + +# At the suborg level — disable labels for matched repos only +disable_plugins: + - plugin: labels + target: all +``` + +**Target options:** + +| Target | Effect | +|--------|--------| +| `self` | Strips the plugin from the declaring layer only | +| `children` | Strips from all layers below | +| `all` | Strips from the declaring layer and all layers below | + +**Important:** Strips are **union-only** — a lower-level config can add more strips but can never re-enable a plugin disabled at a higher level. + +### Additive Plugins + +For plugins where you want Safe-Settings to enforce a baseline but allow teams to add their own items without those additions being removed: + +```yaml +# In settings.yml — never remove labels or teams added outside safe-settings +additive_plugins: + - labels + - teams + - collaborators +``` + +In additive mode, Safe-Settings will **add** and **update** entries defined in YAML but will **never delete** items that exist on GitHub but are absent from the configuration. This is ideal for labels, teams, and collaborators where teams may need to add project-specific items. + +### Externally Defined Status Checks + +For status checks that are managed by CI/CD pipelines rather than Safe-Settings: + +```yaml +branches: + - name: default + protection: + required_status_checks: + contexts: + - "ci/build" # Managed by safe-settings + - "{{EXTERNALLY_DEFINED}}" # Preserve any additional checks set via UI +``` + +This allows Safe-Settings to enforce a minimum set of required status checks while preserving any additional checks configured by teams through the GitHub UI. + +--- + +## Drift Detection and Remediation + +### How Drift Is Detected + +Drift occurs when repository settings are changed outside of Safe-Settings — for example, a repository administrator modifying branch protections through the GitHub UI. + +Safe-Settings detects drift through two mechanisms: + +1. **Real-time webhook events**: When certain settings are changed (branch protections, rulesets, team memberships, collaborator changes), GitHub sends webhook events that trigger Safe-Settings to re-sync the affected repository. + +2. **Scheduled sync (CRON)**: A configurable cron job that periodically compares all managed repositories against the declared policy and remediates any drift. + +### Webhook-Based Remediation + +The following events trigger automatic remediation: + +- `branch_protection_rule` — Modified or deleted branch protections are restored +- `repository_ruleset` — Unauthorized ruleset changes are reverted +- `member` / `team` changes — Unauthorized permission changes are corrected +- `repository.edited` — Settings like default branch or topics are restored + +### Scheduled Sync Configuration + +```env +# Run drift detection every hour +CRON=0 * * * * + +# Or run at 2 AM daily for lower-priority environments +CRON=0 2 * * * +``` + +### Drift Remediation Strategy + +| Priority | Strategy | Use Case | +|----------|----------|----------| +| **Critical** | Real-time webhook + hourly CRON | Production security policies, branch protections | +| **Standard** | Real-time webhook + daily CRON | Team permissions, labels, general settings | +| **Advisory** | Daily CRON only | Low-risk settings where immediate enforcement isn't required | + +--- + +## Multi-Organization Deployments + +For enterprises with multiple GitHub organizations, Safe-Settings can be deployed in several patterns: + +### Pattern 1: One App per Organization + +Deploy a separate Safe-Settings instance for each organization. Each instance has its own admin repo and configuration. + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Org: prod-eng │ │ Org: platform │ │ Org: open-src │ +│ │ │ │ │ │ +│ safe-settings │ │ safe-settings │ │ safe-settings │ +│ admin repo │ │ admin repo │ │ admin repo │ +│ policies A │ │ policies B │ │ policies C │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +**Advantages:** Complete isolation; different policies per org. +**Challenges:** Multiple deployments to manage; policy consistency must be maintained manually. + +### Pattern 2: Shared Policy Templates + +Maintain a shared repository of policy templates and use them as a source for each organization's admin repo: + +``` +┌─────────────────────────────────────────┐ +│ Policy Template Repo │ +│ (Gold-standard YAML templates) │ +└────────┬──────────┬──────────┬──────────┘ + │ │ │ + ┌────▼───┐ ┌────▼───┐ ┌───▼────┐ + │ Org A │ │ Org B │ │ Org C │ + │ admin │ │ admin │ │ admin │ + └────────┘ └────────┘ └────────┘ +``` + +Use CI/CD pipelines (e.g., GitHub Actions) to sync templates to each organization's admin repo, allowing per-org customization while maintaining a consistent baseline. + +### Pattern 3: GitHub Enterprise Server + Cloud + +For organizations using both GitHub Enterprise Server and GitHub.com: + +- Deploy Safe-Settings on-premises for GHES (set `GHE_HOST` environment variable) +- Deploy Safe-Settings in the cloud for GitHub.com organizations +- Use a shared policy template repo to maintain consistency + +--- + +## Security Considerations + +### Protecting the Admin Repository + +The admin repository is the source of truth for all organizational policies. Protect it with: + +1. **Branch protections on the default branch**: Require PR reviews, status checks, and code owner approval. +2. **CODEOWNERS**: Define who can approve changes to different policy files. +3. **Repository visibility**: Keep the admin repo private. +4. **Limited write access**: Only grant write access to authorized policy administrators. +5. **Audit logging**: GitHub's audit log captures all changes to the admin repo. + +### GitHub App Permissions + +Safe-Settings requires specific permissions to function. Follow the principle of least privilege: + +| Permission | Level | Purpose | +|-----------|-------|---------| +| Administration | Read & Write | Manage repository settings | +| Contents | Read & Write | Read config files from admin repo | +| Checks | Read & Write | Report validation results | +| Pull requests | Read & Write | Comment on policy change PRs | +| Custom properties | Read & Write | Manage custom property values | +| Members (org) | Read & Write | Manage team permissions | + +### Secrets Management + +- **Never** commit the GitHub App private key to the admin repo +- Use environment variables, Kubernetes Secrets, or cloud secret managers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) +- Rotate the webhook secret periodically + +### Blocking Manual Overrides + +Enable `BLOCK_REPO_RENAME_BY_HUMAN=true` to prevent repository renames outside of Safe-Settings, maintaining configuration file consistency. + +--- + +## Case Study: Enterprise Rollout + +### Scenario + +A financial services company with 3,000+ repositories across 50 development teams needs to enforce SOX compliance controls, standardize branch protections, and reduce the time to provision new repositories from days to minutes. + +### Phase 1: Discovery and Planning (Week 1–2) + +1. **Audit current state**: Document existing repository configurations across the organization. +2. **Define policy tiers**: Establish three compliance tiers — Standard, Regulated, and Critical. +3. **Map teams to suborgs**: Define suborg membership using custom properties (`compliance_tier: standard|regulated|critical`). +4. **Design CODEOWNERS**: Map approval authority for each policy tier. + +### Phase 2: Pilot Deployment (Week 3–4) + +1. **Deploy Safe-Settings** to a Kubernetes cluster with the Helm chart. +2. **Restrict scope** to 10 pilot repositories using `deployment-settings.yml`. +3. **Define baseline policies** for organization-wide settings. +4. **Test dry-run mode** by creating PRs and validating check results. +5. **Validate drift remediation** by manually changing settings and confirming automatic reversion. + +### Phase 3: Gradual Rollout (Week 5–8) + +1. **Expand scope** to one team (50 repositories) per week. +2. **Create suborg policies** for each compliance tier. +3. **Enable override validators** to prevent weakening of security controls. +4. **Train team leads** on creating repo-specific overrides via PR workflow. + +### Phase 4: Full Deployment (Week 9–10) + +1. **Remove scope restrictions** — Safe-Settings manages all repositories. +2. **Enable scheduled sync** with `CRON=0 * * * *` for hourly drift checks. +3. **Enable `BLOCK_REPO_RENAME_BY_HUMAN`** for configuration consistency. +4. **Document runbooks** for common policy change scenarios. + +### Results + +| Metric | Before | After | +|--------|--------|-------| +| Time to provision new repo | 2–3 days | < 5 minutes | +| Repos with compliant branch protections | 62% | 100% | +| Manual drift incidents per month | 40+ | 0 (auto-remediated) | +| Policy change audit trail | Partial | Complete (Git history) | +| Time to demonstrate compliance | Days | Minutes (YAML-as-evidence) | + +--- + +## Best Practices + +### Policy Design + +1. **Keep `settings.yml` minimal — rulesets and custom roles only**: Any change to `settings.yml` triggers processing for every managed repository. To avoid cascading API calls across thousands of repos, limit org-level settings to resources that use org-scoped API endpoints (rulesets and custom repository roles). Move all repo-scoped settings (teams, labels, repository config, collaborators) to suborg files. + +2. **Use a broad suborg for repo-scoped baselines**: Create a `baseline.yml` suborg with `suborgrepos: ["*"]` to apply default repo-scoped settings (teams, labels, security config) to all repos. This achieves the same coverage as org-level settings but limits the blast radius of changes to the matched subset. + +3. **Use suborgs for team autonomy**: Rather than creating repo-level overrides for every repository, group repos into suborgs by team, project, or compliance tier. + +4. **Prefer custom properties for suborg membership**: Custom properties provide the most flexible and maintainable way to group repositories, as they can be updated without modifying the admin repo. + +5. **Use additive mode for shared resources**: For labels, teams, and collaborators, consider using `additive_plugins` to allow teams to add project-specific items without those additions being removed on the next sync. + +6. **Define validators early**: Establish override validators before teams start creating overrides. This prevents policy weakening from the start. + +### Operational Excellence + +6. **Protect the admin repo**: Apply the same (or stricter) branch protections to the admin repo as you require for production code. + +7. **Use CODEOWNERS strategically**: Grant approval authority at the appropriate level — security team for org settings, team leads for suborg settings, repo owners for repo-specific overrides. + +8. **Monitor check runs**: Set up notifications for failed Safe-Settings check runs to catch configuration issues early. + +9. **Schedule regular sync**: Even with webhook-based enforcement, configure a CRON schedule as a safety net for missed webhooks. + +10. **Version pin your deployment**: Use specific image tags (e.g., `ghcr.io/github/safe-settings:2.1.13`) rather than floating tags to ensure reproducible deployments. + +### Scaling + +11. **Phase your rollout**: Use `deployment-settings.yml` to gradually expand scope. Start with 10 repos, then 100, then 1,000. + +12. **Avoid repo-scoped settings in `settings.yml`**: Changes to `settings.yml` trigger processing for all managed repositories. Keep it to org-level rulesets and custom roles only. Use suborg files for repo-scoped settings to limit the blast radius of any single change. + +13. **Use include/exclude patterns**: For teams and collaborators, use `include` and `exclude` patterns rather than defining settings for every repository individually. + +14. **Monitor API rate limits**: At scale, watch for rate limit consumption. Probot handles this automatically, but awareness helps with capacity planning. + +--- + +## Conclusion + +GitHub Safe-Settings transforms repository governance from a manual, error-prone process into an automated, auditable, and scalable policy-as-code practice. By centralizing configuration in a protected admin repository, enforcing changes through pull request workflows, and continuously remediating drift, organizations can achieve consistent security baselines, streamlined compliance, and empowered development teams. + +Whether managing 50 or 5,000 repositories, Safe-Settings provides the flexibility to balance centralized governance with team autonomy — ensuring that every repository in your organization meets your standards, every time. + +--- + +## Additional Resources + +- **Repository**: [github/safe-settings](https://github.com/github/safe-settings) +- **Deployment Guide**: [docs/deploy.md](deploy.md) +- **GitHub Actions Guide**: [docs/github-action.md](github-action.md) +- **Sample Settings**: [docs/sample-settings/](sample-settings/) +- **AWS Lambda Template**: [SafeSettings-Template](https://github.com/bheemreddy181/SafeSettings-Template) + +--- + +*© 2026 GitHub, Inc. Safe-Settings is licensed under the [ISC License](https://opensource.org/licenses/ISC).* diff --git a/examples/merge-configs-example.js b/examples/merge-configs-example.js new file mode 100644 index 000000000..9c73834ca --- /dev/null +++ b/examples/merge-configs-example.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +/** + * Simple example demonstrating the mergeConfigs function + */ + +const { mergeConfigs } = require('../lib/hubSyncHandler') + +console.log('═══════════════════════════════════════════════════') +console.log(' mergeConfigs() Function Examples') +console.log('═══════════════════════════════════════════════════\n') + +// Example 1: Array Replace Mode +console.log('Example 1: Array Replace Mode (replaceArrays = true)') +console.log('─────────────────────────────────────────────────\n') + +const json1 = ` +teams: + - team-a + - team-b +` + +const json2 = ` +teams: + - team-c +` + +console.log('JSON-1:') +console.log(json1) +console.log('JSON-2:') +console.log(json2) + +const replaced = mergeConfigs(json1, json2, true) +console.log('Result (replace=true):') +console.log(JSON.stringify(replaced, null, 2)) +console.log('✓ Second array REPLACES first array\n') + +// Example 2: Array Append Mode +console.log('Example 2: Array Append Mode (replaceArrays = false)') +console.log('─────────────────────────────────────────────────\n') + +const appended = mergeConfigs(json1, json2, false) +console.log('Result (replace=false):') +console.log(JSON.stringify(appended, null, 2)) +console.log('✓ Second array is APPENDED to first array\n') + +// Example 3: Object Merging +console.log('Example 3: Object Merging') +console.log('─────────────────────────────────────────────────\n') + +const global = ` +repository: + private: true + has_issues: true +` + +const org = ` +repository: + visibility: internal + has_issues: false +` + +console.log('Global Settings:') +console.log(global) +console.log('Organization Settings:') +console.log(org) + +const merged = mergeConfigs(global, org, true) +console.log('Merged Result:') +console.log(JSON.stringify(merged, null, 2)) +console.log('✓ Objects are merged, second takes precedence\n') + +// Example 4: Complex Case with Both Arrays and Objects +console.log('Example 4: Complex Nested Structure') +console.log('─────────────────────────────────────────────────\n') + +const complex1 = ` +repository: + settings: + security: + scanning: true + teams: + - admin-team + - dev-team +labels: + - bug + - feature +` + +const complex2 = ` +repository: + settings: + security: + alerts: true + teams: + - qa-team +labels: + - enhancement +` + +console.log('Config 1:') +console.log(complex1) +console.log('Config 2:') +console.log(complex2) + +console.log('Result with REPLACE mode:') +const complexReplaced = mergeConfigs(complex1, complex2, true) +console.log(JSON.stringify(complexReplaced, null, 2)) + +console.log('\nResult with APPEND mode:') +const complexAppended = mergeConfigs(complex1, complex2, false) +console.log(JSON.stringify(complexAppended, null, 2)) + +// Example 5: Labels - Named Objects with Smart Merging +console.log('\n\nExample 5: Labels with Named Objects (Smart Merge by Name)') +console.log('─────────────────────────────────────────────────\n') + +const baseLabels = ` +labels: + include: + - name: bug + color: CC0000 + description: An issue with the system + - name: feature + color: "336699" + description: New functionality + - name: documentation + color: "0075ca" + description: Documentation improvements +` + +const overlayLabels = ` +labels: + include: + - name: bug + color: FF0000 + description: Bug reports and fixes + - name: enhancement + color: "84b6eb" + description: New feature or request +` + +console.log('Base Config (3 labels):') +console.log(baseLabels) +console.log('Overlay Config (2 labels):') +console.log(overlayLabels) + +console.log('\nResult with REPLACE mode (replaceArrays=true):') +const labelsReplaced = mergeConfigs(baseLabels, overlayLabels, true) +console.log(JSON.stringify(labelsReplaced, null, 2)) +console.log('✓ Entire labels array is REPLACED - only overlay labels remain\n') + +console.log('Result with SMART MERGE mode (replaceArrays=false):') +const labelsSmartMerged = mergeConfigs(baseLabels, overlayLabels, false) +console.log(JSON.stringify(labelsSmartMerged, null, 2)) +console.log('✓ Labels matched by NAME:') +console.log(' - "bug" label UPDATED (color & description changed)') +console.log(' - "feature" and "documentation" PRESERVED from base') +console.log(' - "enhancement" ADDED from overlay') +console.log('✓ Sub-properties (color, description) are merged per label\n') + +// Example 6: Primitive Values - Simple Override +console.log('\n\nExample 6: Primitive Values (Strings, Numbers, Booleans)') +console.log('─────────────────────────────────────────────────\n') + +const basePrimitives = ` +policy_name: P1 +role: "master controller" +version: 1.2 +enabled: true +max_retries: 3 +` + +const overlayPrimitives = ` +policy_name: P2 +version: 2.0 +description: "Updated policy" +` + +console.log('Base Config:') +console.log(basePrimitives) +console.log('Overlay Config:') +console.log(overlayPrimitives) + +console.log('Result (both modes behave the same for primitives):') +const primitivesMerged = mergeConfigs(basePrimitives, overlayPrimitives, true) +console.log(JSON.stringify(primitivesMerged, null, 2)) +console.log('✓ Primitive values from overlay OVERRIDE base values:') +console.log(' - policy_name: P1 → P2 (overridden)') +console.log(' - version: 1.2 → 2.0 (overridden)') +console.log(' - role: "master controller" (preserved - not in overlay)') +console.log(' - enabled: true (preserved - not in overlay)') +console.log(' - max_retries: 3 (preserved - not in overlay)') +console.log(' - description: "Updated policy" (added from overlay)') +console.log('✓ Result: overlay values replace base, base-only values preserved\n') + +// Example 7: Array Deduplication +console.log('\nExample 7: Array of Primitives (Deduplication)') +console.log('─────────────────────────────────────────────────\n') + +const baseTags = ` +tags: + - typescript + - nodejs + - api + - testing +` + +const overlayTags = ` +tags: + - nodejs + - docker + - kubernetes +` + +console.log('Base Config:') +console.log(baseTags) +console.log('Overlay Config:') +console.log(overlayTags) + +console.log('\nResult with REPLACE mode (replaceArrays=true):') +const tagsReplaced = mergeConfigs(baseTags, overlayTags, true) +console.log(JSON.stringify(tagsReplaced, null, 2)) +console.log('✓ Base tags REPLACED - only overlay tags remain\n') + +console.log('Result with SMART MERGE mode (replaceArrays=false):') +const tagsSmartMerged = mergeConfigs(baseTags, overlayTags, false) +console.log(JSON.stringify(tagsSmartMerged, null, 2)) +console.log('✓ Primitive arrays DEDUPLICATED:') +console.log(' - All base tags PRESERVED: typescript, nodejs, api, testing') +console.log(' - Duplicate "nodejs" NOT added again') +console.log(' - Unique overlay tags ADDED: docker, kubernetes') +console.log('✓ Result: union of both arrays with no duplicates\n') + +console.log('\n═══════════════════════════════════════════════════') +console.log(' Usage Summary') +console.log('═══════════════════════════════════════════════════\n') +console.log('const mergeConfigs = require("./lib/mergeConfigs")\n') +console.log('// Replace arrays (default)') +console.log('mergeConfigs(json1, json2, true)\n') +console.log('// Append arrays') +console.log('mergeConfigs(json1, json2, false)\n') diff --git a/examples/smart-merge-example.js b/examples/smart-merge-example.js new file mode 100644 index 000000000..def3df8b3 --- /dev/null +++ b/examples/smart-merge-example.js @@ -0,0 +1,121 @@ +/** + * Smart Array Merge Examples + * + * This example demonstrates the intelligent array merging behavior of mergeConfigs + * when using replaceArrays=false mode: + * + * 1. Simple Arrays (primitives): Automatically deduplicates items + * - Example: ['team-a', 'team-b'] + ['team-b', 'team-c'] → ['team-a', 'team-b', 'team-c'] + * + * 2. Object Arrays: Matches objects by identifying properties (username, name, etc.) + * and merges matching items while appending new ones + * - Example: Collaborator 'alice' with permission 'push' gets updated to 'admin' + * - Example: New collaborator 'charlie' gets appended + * + * 3. Real-World Hub-Sync: Global settings intelligently merge with org-specific overrides + * - Object properties from org settings override global settings + * - Arrays are smart-merged (not duplicated or lost) + * + * This is ideal for configuration management where you want to layer settings + * without losing data or creating duplicates. + */ + +const { mergeConfigs } = require('../lib/hubSyncHandler') + +console.log('='.repeat(60)) +console.log('Smart Array Merge Examples') +console.log('='.repeat(60)) + +// Example 1: Simple array deduplication +console.log('\n1. Simple Team Array - Deduplication') +console.log('-'.repeat(60)) + +const teams1 = ` +teams: + - team-a + - team-b +` + +const teams2 = ` +teams: + - team-b + - team-c +` + +console.log('Config 1:', JSON.stringify({ teams: ['team-a', 'team-b'] })) +console.log('Config 2:', JSON.stringify({ teams: ['team-b', 'team-c'] })) +console.log('\nResult (smart merge):') +console.log(JSON.stringify(mergeConfigs(teams1, teams2, false), null, 2)) + +// Example 2: Complex object array with matching +console.log('\n2. Collaborators Array - Match and Merge by Username') +console.log('-'.repeat(60)) + +const collab1 = ` +collaborators: + - username: alice + permission: push + - username: bob + permission: pull +` + +const collab2 = ` +collaborators: + - username: alice + permission: admin + - username: charlie + permission: push +` + +console.log('Config 1:') +console.log(collab1.trim()) +console.log('\nConfig 2:') +console.log(collab2.trim()) +console.log('\nResult (alice updated to admin, bob kept, charlie added):') +console.log(JSON.stringify(mergeConfigs(collab1, collab2, false), null, 2)) + +// Example 3: Real-world hub-sync scenario +console.log('\n3. Real-World Hub-Sync Settings Merge') +console.log('-'.repeat(60)) + +const globalSettings = ` +repository: + description: "Global default description" + private: true + has_issues: true + +collaborators: + - username: global-admin + permission: admin + - username: regpaco + permission: push + +teams: + - core + - docs +` + +const orgSettings = ` +repository: + description: "Org-specific override" + has_wiki: true + +collaborators: + - username: regpaco + permission: admin + - username: beetlejuice + permission: pull + +teams: + - docs + - globalteam +` + +console.log('Global Settings has: global-admin (admin), regpaco (push), teams: core, docs') +console.log('Org Settings has: regpaco (admin), beetlejuice (pull), teams: docs, globalteam') +console.log('\nResult (smart merge):') +const merged = mergeConfigs(globalSettings, orgSettings, false) +console.log(JSON.stringify(merged, null, 2)) +console.log('\nNote: regpaco upgraded to admin, all collaborators preserved, teams deduplicated') + +console.log('\n' + '='.repeat(60)) diff --git a/generate-settings.js b/generate-settings.js new file mode 100644 index 000000000..ab752fbaf --- /dev/null +++ b/generate-settings.js @@ -0,0 +1,156 @@ +/* eslint-disable camelcase */ +/** + * Standalone CLI to generate safe-settings YAML from the *current* state of a + * repo / org / collection-of-repos and write it to the local filesystem. + * + * Usage (env or flags): + * SOURCE_TYPE=repo SOURCE_VALUE=my-repo node generate-settings.js + * SOURCE_TYPE=org SOURCE_VALUE=my-org node generate-settings.js + * SOURCE_TYPE=custom-property SOURCE_VALUE=Team=backend node generate-settings.js + * + * node generate-settings.js --source-type repo --source-value my-repo \ + * --owner my-org --output-dir ./out --overwrite + * + * When overwrite is false (default) and the target file already exists, a + * `.sample.yml` file is written instead of replacing the existing file. + */ +const fs = require('fs') +const path = require('path') + +// Load .env into process.env before any module reads it (lib/env.js reads at +// require time). Mirrors the lightweight parser used by smoke-test.js so we +// avoid adding a dotenv dependency. +function loadEnv () { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) return + const lines = fs.readFileSync(envPath, 'utf8').split('\n') + let currentKey = null + let currentValue = '' + let inMultiline = false + + for (const line of lines) { + if (inMultiline) { + currentValue += '\n' + line + if (line.includes('"') || line.includes("'")) { + const val = currentValue.replace(/^["']|["']$/g, '') + // Like dotenv: .env values don't override existing env vars + if (!(currentKey in process.env)) process.env[currentKey] = val + inMultiline = false + } + continue + } + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + currentKey = trimmed.slice(0, eqIdx).trim() + currentValue = trimmed.slice(eqIdx + 1).trim() + if ((currentValue.startsWith('"') && !currentValue.endsWith('"')) || + (currentValue.startsWith("'") && !currentValue.endsWith("'"))) { + inMultiline = true + continue + } + const val = currentValue.replace(/^["']|["']$/g, '') + if (!(currentKey in process.env)) process.env[currentKey] = val + } +} + +loadEnv() +const { createProbot } = require('probot') +const SettingsGenerator = require('./lib/settingsGenerator') + +function parseArgs (argv) { + const args = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + if (key === 'overwrite') { + args.overwrite = true + } else { + args[key] = argv[++i] + } + } + } + return args +} + +function resolveOptions () { + const args = parseArgs(process.argv.slice(2)) + const sourceType = args['source-type'] || process.env.SOURCE_TYPE + const sourceValue = args['source-value'] || process.env.SOURCE_VALUE + const propertyName = args['property-name'] || process.env.SOURCE_PROPERTY_NAME + const owner = args.owner || process.env.OWNER || process.env.GITHUB_ORG || process.env.GH_ORG + const outputDir = args['output-dir'] || process.env.OUTPUT_DIR || '.' + const overwrite = args.overwrite || process.env.OVERWRITE === 'true' + + if (!sourceType || !sourceValue) { + throw new Error('SOURCE_TYPE and SOURCE_VALUE (or --source-type/--source-value) are required') + } + return { sourceType, sourceValue, propertyName, owner, outputDir, overwrite } +} + +/** + * Get an authenticated installation octokit + the org login. + * If OWNER is provided we match its installation, otherwise use the first. + */ +async function getInstallationClient (probot, owner) { + const app = await probot.auth() + const installations = await app.paginate( + app.apps.listInstallations.endpoint.merge({ per_page: 100 }) + ) + if (installations.length === 0) { + throw new Error('No installations found for this GitHub App') + } + const installation = owner + ? installations.find(i => i.account.login.toLowerCase() === owner.toLowerCase()) + : installations[0] + if (!installation) { + throw new Error(`No installation found for owner "${owner}"`) + } + const github = await probot.auth(installation.id) + return { github, owner: installation.account.login } +} + +/** + * Write content to disk honoring the overwrite/.sample rule. + * @returns {string} the path actually written + */ +function writeOutput (outputDir, filePath, content, overwrite) { + let target = path.join(outputDir, filePath) + if (!overwrite && fs.existsSync(target)) { + const parsed = path.parse(target) + target = path.join(parsed.dir, `${parsed.name}.sample${parsed.ext}`) + } + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, content) + return target +} + +async function main () { + const opts = resolveOptions() + const probot = createProbot() + probot.log.info(`Generating settings: source-type=${opts.sourceType} source-value=${opts.sourceValue}`) + + const { github, owner } = await getInstallationClient(probot, opts.owner) + const generator = new SettingsGenerator(github, owner, { log: probot.log }) + + const { filePath, yaml } = await generator.generate({ + sourceType: opts.sourceType, + sourceValue: opts.sourceValue, + propertyName: opts.propertyName + }) + + const written = writeOutput(opts.outputDir, filePath, yaml, opts.overwrite) + probot.log.info(`Wrote ${written}`) + process.stdout.write(`${written}\n`) +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write(`Error generating settings: ${error.stack || error}\n`) + process.exit(1) + }) +} + +module.exports = { parseArgs, resolveOptions, writeOutput, getInstallationClient } diff --git a/hubSyncHandler.log b/hubSyncHandler.log new file mode 100644 index 000000000..f8812c594 --- /dev/null +++ b/hubSyncHandler.log @@ -0,0 +1,1000 @@ +2026-06-23T18:27:37.508Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:27:40.546Z [DEBUG] Check run was created! +2026-06-23T18:27:40.547Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:27:49.350Z [DEBUG] Is Admin repo event false +2026-06-23T18:27:49.350Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:27:50.780Z [DEBUG] Pull_request opened ! +2026-06-23T18:27:50.780Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:27:50.780Z [DEBUG] Is Admin repo event false +2026-06-23T18:27:50.882Z [DEBUG] Check run was created! +2026-06-23T18:27:50.882Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:27:50.882Z [DEBUG] Is Admin repo event false +2026-06-23T18:27:52.106Z [DEBUG] Check run was created! +2026-06-23T18:27:52.106Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:27:52.106Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:08.968Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:28:08.968Z [INFO] Received 'pull_request.closed' event: 577 +2026-06-23T18:28:09.459Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:09.459Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:28:12.757Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:28:12.757Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:28:13.974Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:28:13.974Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:28:14.824Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:28:14.824Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:28:15.845Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:28:15.845Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:28:18.563Z [DEBUG] Check run was created! +2026-06-23T18:28:18.563Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:28:48.403Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:48.403Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:28:49.596Z [DEBUG] Pull_request opened ! +2026-06-23T18:28:49.596Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:49.596Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:28:49.879Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:28:49.879Z [DEBUG] Check run was created! +2026-06-23T18:28:49.879Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:50.664Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:28:50.664Z [DEBUG] Is Admin repo event false +2026-06-23T18:28:50.664Z [DEBUG] Check run was created! +2026-06-23T18:29:05.197Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:29:05.197Z [INFO] Received 'pull_request.closed' event: 578 +2026-06-23T18:29:11.295Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:11.295Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:12.428Z [DEBUG] Check run was created! +2026-06-23T18:29:12.428Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:12.428Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:12.604Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:12.604Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:12.604Z [DEBUG] Pull_request opened ! +2026-06-23T18:29:14.725Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:14.725Z [DEBUG] Check run was created! +2026-06-23T18:29:14.725Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:29.398Z [INFO] Received 'pull_request.closed' event: 579 +2026-06-23T18:29:29.398Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:29:30.303Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:30.303Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:39.987Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:29:39.987Z [DEBUG] Check run was created! +2026-06-23T18:29:50.834Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:50.834Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:52.034Z [DEBUG] Pull_request opened ! +2026-06-23T18:29:52.034Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:52.034Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:52.081Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:52.081Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:52.081Z [DEBUG] Check run was created! +2026-06-23T18:29:53.367Z [DEBUG] Is Admin repo event false +2026-06-23T18:29:53.367Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:29:53.367Z [DEBUG] Check run was created! +2026-06-23T18:30:14.841Z [INFO] Received 'pull_request.closed' event: 580 +2026-06-23T18:30:14.841Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:30:16.064Z [DEBUG] Is Admin repo event false +2026-06-23T18:30:16.064Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:30:23.518Z [DEBUG] Check run was created! +2026-06-23T18:30:23.518Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:30:50.145Z [DEBUG] Is Admin repo event false +2026-06-23T18:30:50.145Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:30:51.313Z [DEBUG] Check run was created! +2026-06-23T18:30:51.313Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:30:51.313Z [DEBUG] Is Admin repo event false +2026-06-23T18:30:51.636Z [DEBUG] Is Admin repo event false +2026-06-23T18:30:51.636Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:30:51.636Z [DEBUG] Pull_request opened ! +2026-06-23T18:30:53.721Z [DEBUG] Check run was created! +2026-06-23T18:30:53.721Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:30:53.721Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:08.490Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:31:08.490Z [INFO] Received 'pull_request.closed' event: 581 +2026-06-23T18:31:09.788Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:31:09.788Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:14.066Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:31:14.066Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:31:15.144Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:31:15.144Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:31:16.176Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:31:16.176Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:31:17.134Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:31:17.134Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:31:48.946Z [DEBUG] Check run was created! +2026-06-23T18:31:48.946Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:31:49.147Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:49.147Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:31:50.321Z [DEBUG] Check run was created! +2026-06-23T18:31:50.321Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:31:50.321Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:50.544Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:50.544Z [DEBUG] Pull_request opened ! +2026-06-23T18:31:50.544Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:31:52.830Z [DEBUG] Is Admin repo event false +2026-06-23T18:31:52.830Z [DEBUG] Check run was created! +2026-06-23T18:31:52.830Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:07.658Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:32:07.658Z [INFO] Received 'pull_request.closed' event: 582 +2026-06-23T18:32:08.754Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:08.754Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:13.218Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:32:13.218Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:32:14.175Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:32:14.175Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:32:15.406Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:32:15.406Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:32:17.907Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:32:17.907Z [DEBUG] Check run was created! +2026-06-23T18:32:24.350Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:32:24.350Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:32:29.823Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:29.823Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:30.575Z [DEBUG] Pull_request opened ! +2026-06-23T18:32:30.575Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:30.575Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:31.295Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:31.295Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:31.295Z [DEBUG] Check run was created! +2026-06-23T18:32:31.953Z [DEBUG] Check run was created! +2026-06-23T18:32:31.953Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:31.953Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:47.806Z [INFO] Received 'pull_request.closed' event: 583 +2026-06-23T18:32:47.806Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:32:48.759Z [DEBUG] Is Admin repo event false +2026-06-23T18:32:48.759Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:32:54.880Z [DEBUG] Check run was created! +2026-06-23T18:32:54.880Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:33:23.143Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:33:23.143Z [DEBUG] Is Admin repo event false +2026-06-23T18:33:24.108Z [DEBUG] Is Admin repo event false +2026-06-23T18:33:24.108Z [DEBUG] Pull_request opened ! +2026-06-23T18:33:24.108Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:33:24.536Z [DEBUG] Is Admin repo event false +2026-06-23T18:33:24.536Z [DEBUG] Check run was created! +2026-06-23T18:33:24.536Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:33:25.334Z [DEBUG] Check run was created! +2026-06-23T18:33:25.334Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:33:25.334Z [DEBUG] Is Admin repo event false +2026-06-23T18:33:42.365Z [INFO] Received 'pull_request.closed' event: 584 +2026-06-23T18:33:42.365Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:33:43.330Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:33:43.330Z [DEBUG] Is Admin repo event false +2026-06-23T18:33:46.829Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:46.829Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:48.432Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:48.432Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:48.443Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:48.443Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:49.427Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:49.427Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:50.471Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:50.471Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:51.614Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:51.614Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:52.519Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:52.518Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:53.320Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:53.320Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:54.313Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:54.313Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:55.352Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:55.352Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:56.191Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:56.191Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:57.385Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:57.385Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:58.454Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:58.454Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:33:59.424Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:33:59.424Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:00.386Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:00.386Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:01.360Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:01.360Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:02.376Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:02.376Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:03.381Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:03.381Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:04.387Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:04.387Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:05.350Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:05.350Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:06.445Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:06.445Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:07.315Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:07.315Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:08.516Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:08.516Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:10.510Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:10.510Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:10.541Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:10.541Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:11.419Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:11.419Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:12.406Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:12.406Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:13.406Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:13.406Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:14.324Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:14.324Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:15.413Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:15.413Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:16.493Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:16.493Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:17.553Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:17.553Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:18.502Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:18.502Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:19.354Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:19.354Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:20.286Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:20.286Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:21.371Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:21.371Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:22.300Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:22.300Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:24.302Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:24.302Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:24.494Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:24.494Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:25.418Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:25.418Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:26.305Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:26.305Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:27.348Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:27.348Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:28.349Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:28.349Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:29.349Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:29.349Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:30.548Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:30.548Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:31.324Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:31.324Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:32.349Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:32.349Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:33.326Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:33.326Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:34.475Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:34.475Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:35.379Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:35.379Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:36.269Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:36.269Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:37.509Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:37.509Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:38.360Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:38.360Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:39.343Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:39.343Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:40.331Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:40.332Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:41.335Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:41.335Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:42.320Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:42.320Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:43.431Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:43.431Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:44.361Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:44.361Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:45.343Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:45.343Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:46.404Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:46.404Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:47.455Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:47.455Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:48.406Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:48.406Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:49.255Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:49.255Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:50.781Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:50.781Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:51.371Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:51.371Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:52.417Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:52.417Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:53.337Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:53.337Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:54.524Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:54.524Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:55.695Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:55.695Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:56.344Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:56.344Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:57.432Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:57.432Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:58.644Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:34:58.644Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:59.286Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:34:59.286Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:00.665Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:00.665Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:01.261Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:01.261Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:02.307Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:02.307Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:03.347Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:03.347Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:04.438Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:04.438Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:05.320Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:05.320Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:06.379Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:06.379Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:06.893Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:06.893Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:07.479Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:07.479Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:08.046Z [DEBUG] Pull_request opened ! +2026-06-23T18:35:08.046Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:08.046Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:10.276Z [DEBUG] Check run was created! +2026-06-23T18:35:10.276Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:10.276Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:11.067Z [DEBUG] Check run was created! +2026-06-23T18:35:11.067Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:11.067Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:12.042Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:35:12.042Z [DEBUG] Check run was created! +2026-06-23T18:35:12.609Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T18:35:15.421Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T18:35:15.421Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T18:35:15.421Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T18:35:15.679Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:35:15.679Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:35:15.679Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T18:35:15.898Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:35:15.898Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T18:35:16.904Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T18:35:16.904Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T18:35:16.904Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T18:35:17.148Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:35:17.148Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T18:35:17.148Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T18:35:17.361Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:35:17.361Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T18:35:18.376Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T18:35:18.376Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T18:35:18.376Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T18:35:18.692Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:35:18.692Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T18:35:18.692Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:35:18.916Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:35:18.916Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T18:35:25.853Z [INFO] Received 'pull_request.closed' event: 585 +2026-06-23T18:35:25.854Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:35:26.702Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:26.701Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:30.545Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:30.545Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:31.787Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:31.787Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:32.795Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:32.795Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:33.500Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:33.500Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:34.675Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:34.675Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:35.495Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:35.495Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:35:36.675Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:35:36.675Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:37.509Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:35:37.509Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:35:40.548Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:35:40.548Z [DEBUG] Check run was created! +2026-06-23T18:35:48.895Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:48.895Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:49.990Z [DEBUG] Pull_request opened ! +2026-06-23T18:35:49.990Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:49.990Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:50.305Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:50.305Z [DEBUG] Check run was created! +2026-06-23T18:35:50.305Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:51.538Z [DEBUG] Is Admin repo event false +2026-06-23T18:35:51.538Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:35:51.538Z [DEBUG] Check run was created! +2026-06-23T18:35:55.202Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T18:35:57.404Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T18:35:57.404Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T18:35:57.404Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T18:35:57.637Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T18:35:57.637Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:35:57.637Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:35:57.836Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T18:35:57.836Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:35:58.624Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T18:35:58.624Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T18:35:58.624Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T18:35:58.858Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:35:58.858Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T18:35:58.858Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T18:35:59.065Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:35:59.066Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T18:35:59.848Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T18:35:59.848Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T18:35:59.848Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T18:36:00.083Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T18:36:00.083Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:36:00.083Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:36:00.351Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:36:00.351Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T18:36:07.698Z [INFO] Received 'pull_request.closed' event: 586 +2026-06-23T18:36:07.698Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:36:08.588Z [DEBUG] Is Admin repo event false +2026-06-23T18:36:08.588Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:36:15.883Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:36:15.883Z [DEBUG] Check run was created! +2026-06-23T18:36:43.469Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:36:43.469Z [DEBUG] Is Admin repo event false +2026-06-23T18:36:44.674Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:36:44.674Z [DEBUG] Pull_request opened ! +2026-06-23T18:36:44.674Z [DEBUG] Is Admin repo event false +2026-06-23T18:36:44.720Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:36:44.720Z [DEBUG] Is Admin repo event false +2026-06-23T18:36:44.720Z [DEBUG] Check run was created! +2026-06-23T18:36:46.051Z [DEBUG] Check run was created! +2026-06-23T18:36:46.051Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:36:46.051Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:02.077Z [INFO] Received 'pull_request.closed' event: 587 +2026-06-23T18:37:02.077Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:37:02.991Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:02.991Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:09.381Z [DEBUG] Check run was created! +2026-06-23T18:37:09.381Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:37:19.455Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:37:19.455Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:37:25.142Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:25.142Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:26.089Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:26.089Z [DEBUG] Pull_request opened ! +2026-06-23T18:37:26.089Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:26.594Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:26.594Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:26.594Z [DEBUG] Check run was created! +2026-06-23T18:37:28.278Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:28.278Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:28.278Z [DEBUG] Check run was created! +2026-06-23T18:37:33.317Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T18:37:35.516Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T18:37:35.516Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T18:37:35.516Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T18:37:35.833Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:37:35.834Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T18:37:35.834Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:37:36.087Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T18:37:36.087Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:37:36.820Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T18:37:36.820Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T18:37:36.820Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T18:37:37.049Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T18:37:37.049Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:37:37.049Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T18:37:37.306Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T18:37:37.306Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:37:38.014Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T18:37:38.014Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T18:37:38.014Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T18:37:38.332Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T18:37:38.332Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T18:37:38.332Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T18:37:38.626Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T18:37:38.626Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T18:37:44.259Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:37:44.259Z [INFO] Received 'pull_request.closed' event: 588 +2026-06-23T18:37:44.787Z [DEBUG] Is Admin repo event false +2026-06-23T18:37:44.787Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:37:47.418Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:37:47.418Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:37:50.815Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:37:50.815Z [DEBUG] Check run was created! +2026-06-23T18:38:20.099Z [DEBUG] Is Admin repo event false +2026-06-23T18:38:20.099Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:38:21.265Z [DEBUG] Is Admin repo event false +2026-06-23T18:38:21.265Z [DEBUG] Pull_request opened ! +2026-06-23T18:38:21.265Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:38:21.483Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:38:21.483Z [DEBUG] Check run was created! +2026-06-23T18:38:21.483Z [DEBUG] Is Admin repo event false +2026-06-23T18:38:22.506Z [DEBUG] Check run was created! +2026-06-23T18:38:22.506Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:38:22.506Z [DEBUG] Is Admin repo event false +2026-06-23T18:38:38.269Z [INFO] Received 'pull_request.closed' event: 589 +2026-06-23T18:38:38.269Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:38:39.183Z [DEBUG] Is Admin repo event false +2026-06-23T18:38:39.183Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:38:46.202Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:38:46.202Z [DEBUG] Check run was created! +2026-06-23T18:39:02.013Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:39:02.013Z [DEBUG] Pull_request opened ! +2026-06-23T18:39:02.013Z [DEBUG] Is Admin repo event false +2026-06-23T18:39:03.259Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:39:03.259Z [DEBUG] Check run was created! +2026-06-23T18:39:03.259Z [DEBUG] Is Admin repo event false +2026-06-23T18:39:07.298Z [DEBUG] Is Admin repo event false +2026-06-23T18:39:07.298Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:18.865Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:41:18.865Z [INFO] Received 'pull_request.closed' event: 590 +2026-06-23T18:41:19.734Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:19.734Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:26.208Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:41:26.208Z [DEBUG] Check run was created! +2026-06-23T18:41:39.154Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:39.154Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:40.540Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:40.540Z [DEBUG] Pull_request opened ! +2026-06-23T18:41:40.540Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:40.696Z [DEBUG] Check run was created! +2026-06-23T18:41:40.696Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:40.696Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:42.058Z [DEBUG] Check run was created! +2026-06-23T18:41:42.058Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:42.058Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:41:57.967Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:41:57.967Z [INFO] Received 'pull_request.closed' event: 591 +2026-06-23T18:41:59.004Z [DEBUG] Is Admin repo event false +2026-06-23T18:41:59.004Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:04.088Z [DEBUG] Check run was created! +2026-06-23T18:42:04.088Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:42:19.107Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:19.107Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:19.768Z [DEBUG] Pull_request opened ! +2026-06-23T18:42:19.768Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:19.768Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:20.301Z [DEBUG] Check run was created! +2026-06-23T18:42:20.301Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:20.301Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:20.949Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:20.949Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:20.949Z [DEBUG] Check run was created! +2026-06-23T18:42:37.280Z [INFO] Received 'pull_request.closed' event: 592 +2026-06-23T18:42:37.280Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:42:38.135Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:38.135Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:42:43.693Z [DEBUG] Check run was created! +2026-06-23T18:42:43.693Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:42:59.063Z [DEBUG] Is Admin repo event false +2026-06-23T18:42:59.063Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:00.723Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:00.723Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:02.004Z [DEBUG] Check run was created! +2026-06-23T18:43:02.004Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:02.004Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:02.105Z [DEBUG] Pull_request opened ! +2026-06-23T18:43:02.105Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:02.105Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:04.541Z [DEBUG] Check run was created! +2026-06-23T18:43:04.541Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:04.541Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:18.566Z [INFO] Received 'pull_request.closed' event: 593 +2026-06-23T18:43:18.566Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:43:19.513Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:19.513Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:22.104Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:43:22.104Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:23.211Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:43:23.211Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:24.272Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:24.272Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:43:25.614Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:43:25.614Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:29.635Z [DEBUG] deploymentConfig is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:43:29.635Z [DEBUG] repository.created payload from {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:30.178Z [DEBUG] config for ref undefined is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:43:30.620Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:30.620Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:30.775Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:43:30.775Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:43:32.338Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:43:32.338Z [DEBUG] Check run was created! +2026-06-23T18:43:33.683Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:43:33.683Z [DEBUG] Check run was created! +2026-06-23T18:43:55.924Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:55.924Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:56.275Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:56.275Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:56.275Z [DEBUG] Pull_request opened ! +2026-06-23T18:43:56.987Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:56.987Z [DEBUG] Check run was created! +2026-06-23T18:43:56.987Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:57.995Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:43:57.995Z [DEBUG] Is Admin repo event false +2026-06-23T18:43:57.995Z [DEBUG] Check run was created! +2026-06-23T18:44:13.385Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:44:13.385Z [INFO] Received 'pull_request.closed' event: 594 +2026-06-23T18:44:14.377Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:14.377Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:19.742Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:44:19.742Z [DEBUG] Check run was created! +2026-06-23T18:44:48.958Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:48.958Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:50.170Z [DEBUG] Pull_request opened ! +2026-06-23T18:44:50.170Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:50.170Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:50.208Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:50.208Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:50.208Z [DEBUG] Check run was created! +2026-06-23T18:44:51.354Z [DEBUG] Check run was created! +2026-06-23T18:44:51.354Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:51.354Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:53.421Z [INFO] Received 'pull_request.closed' event: 595 +2026-06-23T18:44:53.421Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:44:54.727Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:44:54.727Z [DEBUG] Is Admin repo event false +2026-06-23T18:44:57.279Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:44:57.279Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:44:58.050Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:44:58.050Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:44:59.138Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:44:59.138Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:00.006Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:00.006Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:00.947Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:00.947Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:03.373Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:03.373Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:04.338Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:04.338Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:05.765Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:45:05.765Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:06.468Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:06.468Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:45:09.675Z [DEBUG] Check run was created! +2026-06-23T18:45:09.675Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:45:12.765Z [DEBUG] deploymentConfig is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:12.765Z [DEBUG] Repository Ruleset edited by {"login":"decyjphr_fabrikam","id":81267703,"node_id":"MDQ6VXNlcjgxMjY3NzAz","avatar_url":"https://avatars.githubusercontent.com/u/81267703?v=4","gravatar_id":"","url":"https://api.github.com/users/decyjphr_fabrikam","html_url":"https://github.com/decyjphr_fabrikam","followers_url":"https://api.github.com/users/decyjphr_fabrikam/followers","following_url":"https://api.github.com/users/decyjphr_fabrikam/following{/other_user}","gists_url":"https://api.github.com/users/decyjphr_fabrikam/gists{/gist_id}","starred_url":"https://api.github.com/users/decyjphr_fabrikam/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/decyjphr_fabrikam/subscriptions","organizations_url":"https://api.github.com/users/decyjphr_fabrikam/orgs","repos_url":"https://api.github.com/users/decyjphr_fabrikam/repos","events_url":"https://api.github.com/users/decyjphr_fabrikam/events{/privacy}","received_events_url":"https://api.github.com/users/decyjphr_fabrikam/received_events","type":"User","user_view_type":"public","site_admin":false} +2026-06-23T18:45:12.765Z [DEBUG] Repository Repository edited by a Human +2026-06-23T18:45:13.131Z [DEBUG] config for ref undefined is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:15.743Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:15.743Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:16.964Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:16.964Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:17.819Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:17.819Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:18.868Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:18.868Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:19.981Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:19.981Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:45:22.195Z [DEBUG] Check run was created! +2026-06-23T18:45:22.195Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:45:30.477Z [DEBUG] Repository Ruleset edited by {"login":"decyjphr_fabrikam","id":81267703,"node_id":"MDQ6VXNlcjgxMjY3NzAz","avatar_url":"https://avatars.githubusercontent.com/u/81267703?v=4","gravatar_id":"","url":"https://api.github.com/users/decyjphr_fabrikam","html_url":"https://github.com/decyjphr_fabrikam","followers_url":"https://api.github.com/users/decyjphr_fabrikam/followers","following_url":"https://api.github.com/users/decyjphr_fabrikam/following{/other_user}","gists_url":"https://api.github.com/users/decyjphr_fabrikam/gists{/gist_id}","starred_url":"https://api.github.com/users/decyjphr_fabrikam/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/decyjphr_fabrikam/subscriptions","organizations_url":"https://api.github.com/users/decyjphr_fabrikam/orgs","repos_url":"https://api.github.com/users/decyjphr_fabrikam/repos","events_url":"https://api.github.com/users/decyjphr_fabrikam/events{/privacy}","received_events_url":"https://api.github.com/users/decyjphr_fabrikam/received_events","type":"User","user_view_type":"public","site_admin":false} +2026-06-23T18:45:30.477Z [DEBUG] Repository Repository edited by a Human +2026-06-23T18:45:30.477Z [DEBUG] deploymentConfig is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:30.770Z [DEBUG] config for ref undefined is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:33.089Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:33.089Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:34.197Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:34.197Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:35.117Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:35.117Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:36.082Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:36.082Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:37.311Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:45:37.311Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:39.317Z [DEBUG] Check run was created! +2026-06-23T18:45:39.317Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:45:47.853Z [DEBUG] Repository Repository edited by a Human +2026-06-23T18:45:47.853Z [DEBUG] Repository Ruleset edited by {"login":"decyjphr_fabrikam","id":81267703,"node_id":"MDQ6VXNlcjgxMjY3NzAz","avatar_url":"https://avatars.githubusercontent.com/u/81267703?v=4","gravatar_id":"","url":"https://api.github.com/users/decyjphr_fabrikam","html_url":"https://github.com/decyjphr_fabrikam","followers_url":"https://api.github.com/users/decyjphr_fabrikam/followers","following_url":"https://api.github.com/users/decyjphr_fabrikam/following{/other_user}","gists_url":"https://api.github.com/users/decyjphr_fabrikam/gists{/gist_id}","starred_url":"https://api.github.com/users/decyjphr_fabrikam/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/decyjphr_fabrikam/subscriptions","organizations_url":"https://api.github.com/users/decyjphr_fabrikam/orgs","repos_url":"https://api.github.com/users/decyjphr_fabrikam/repos","events_url":"https://api.github.com/users/decyjphr_fabrikam/events{/privacy}","received_events_url":"https://api.github.com/users/decyjphr_fabrikam/received_events","type":"User","user_view_type":"public","site_admin":false} +2026-06-23T18:45:47.853Z [DEBUG] deploymentConfig is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:48.203Z [DEBUG] config for ref undefined is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T18:45:50.741Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:50.741Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:51.625Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:51.625Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:52.820Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:52.820Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:53.822Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:53.822Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:45:55.045Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:45:55.045Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:45:57.610Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:45:57.610Z [DEBUG] Check run was created! +2026-06-23T18:46:10.799Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:10.799Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:12.000Z [DEBUG] Pull_request opened ! +2026-06-23T18:46:12.000Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:12.000Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:12.044Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:12.044Z [DEBUG] Check run was created! +2026-06-23T18:46:12.044Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:13.122Z [DEBUG] Check run was created! +2026-06-23T18:46:13.122Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:13.122Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:29.087Z [INFO] Received 'pull_request.closed' event: 596 +2026-06-23T18:46:29.087Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:46:30.183Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:30.183Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:33.650Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:46:33.650Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:46:34.616Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:46:34.617Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:46:35.666Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:46:35.666Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:46:36.578Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:46:36.578Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:46:39.092Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:46:39.092Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:46:40.831Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:46:40.831Z [DEBUG] Check run was created! +2026-06-23T18:46:49.859Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:49.859Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:50.781Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:50.781Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:50.781Z [DEBUG] Pull_request opened ! +2026-06-23T18:46:51.232Z [DEBUG] Check run was created! +2026-06-23T18:46:51.232Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:51.232Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:46:52.140Z [DEBUG] Check run was created! +2026-06-23T18:46:52.140Z [DEBUG] Is Admin repo event false +2026-06-23T18:46:52.140Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:09.041Z [INFO] Pull request.closed is not from master admin repo/org (jefeish-training/safe-settings-config-master), ignoring +2026-06-23T18:47:09.041Z [INFO] Received 'pull_request.closed' event: 597 +2026-06-23T18:47:09.512Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:09.512Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:13.039Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:47:13.039Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:14.070Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:47:14.070Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:15.097Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:15.097Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:47:16.002Z [DEBUG] Custom Property Value Updated for a repo by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:16.002Z [DEBUG] Custom Property Value edited by Bot +2026-06-23T18:47:18.166Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:18.166Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:47:20.301Z [DEBUG] Not triggered by Safe-settings... +2026-06-23T18:47:20.301Z [DEBUG] Check run was created! +2026-06-23T18:47:26.518Z [DEBUG] Repository Unarchived by a Bot +2026-06-23T18:47:39.447Z [DEBUG] Repository Ruleset edited by Bot +2026-06-23T18:47:39.447Z [DEBUG] Repository Ruleset edited by {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T18:47:42.658Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:42.658Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:44.121Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:44.121Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:45.496Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:45.496Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:46.516Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:46.516Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:47.544Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:47.544Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T18:47:48.911Z [DEBUG] Is Admin repo event false +2026-06-23T18:47:48.911Z [DEBUG] Not working on the Admin repo, returning... +2026-06-23T19:32:44.038Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T19:32:45.673Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T19:32:46.072Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T19:32:46.072Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T19:32:46.072Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T19:32:46.388Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:32:46.388Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T19:32:46.388Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:46.606Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:46.606Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T19:32:47.334Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T19:32:47.334Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T19:32:47.334Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T19:32:47.581Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T19:32:47.581Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T19:32:47.581Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T19:32:47.658Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T19:32:47.658Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:47.658Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T19:32:47.837Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:32:47.837Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T19:32:47.837Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:47.876Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:47.876Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T19:32:48.095Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T19:32:48.095Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:48.676Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T19:32:48.676Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T19:32:48.676Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T19:32:48.887Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T19:32:48.887Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T19:32:48.887Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T19:32:48.907Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T19:32:48.907Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:32:48.907Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:49.297Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:49.297Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T19:32:49.297Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T19:32:49.332Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:49.332Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T19:32:49.508Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:49.508Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T19:32:50.231Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T19:32:50.231Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T19:32:50.231Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T19:32:50.470Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T19:32:50.470Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:32:50.470Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:32:50.715Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:32:50.716Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T19:35:11.348Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T19:35:13.814Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T19:35:13.814Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T19:35:13.814Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T19:35:14.080Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:35:14.080Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T19:35:14.080Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:35:14.288Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:35:14.288Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T19:35:15.232Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T19:35:15.232Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T19:35:15.232Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T19:35:15.474Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T19:35:15.474Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:35:15.474Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T19:35:15.692Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T19:35:15.691Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:35:16.830Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T19:35:16.830Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T19:35:16.830Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T19:35:17.063Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:35:17.063Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:35:17.063Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T19:35:17.286Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:35:17.286Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T19:54:33.731Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T19:54:34.691Z [INFO] Starting commit and sync status fetch for copilot-for-emus,decyjphr-emu,decyjphr-training,jefeish-migration-test,jefeish-test,jefeish-test1,jefeish-training organizations... +2026-06-23T19:54:35.630Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T19:54:35.630Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T19:54:35.630Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T19:54:35.895Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:54:35.895Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:35.895Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T19:54:36.095Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:36.095Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T19:54:36.472Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-migration-test +2026-06-23T19:54:36.472Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-migration-test: .github/safe-settings/organizations/jefeish-migration-test +2026-06-23T19:54:36.472Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-migration-test: main +2026-06-23T19:54:36.742Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:54:36.742Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:36.742Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-migration-test, + ref: main +2026-06-23T19:54:36.819Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T19:54:36.819Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T19:54:36.820Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T19:54:36.952Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:36.952Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-migration-test +2026-06-23T19:54:37.027Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T19:54:37.027Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T19:54:37.027Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:37.315Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:37.315Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T19:54:37.737Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test: main +2026-06-23T19:54:37.737Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test: .github/safe-settings/organizations/jefeish-test +2026-06-23T19:54:37.737Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test +2026-06-23T19:54:38.021Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: settings.yml +2026-06-23T19:54:38.021Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:38.021Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test, + ref: main +2026-06-23T19:54:38.043Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T19:54:38.043Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T19:54:38.043Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T19:54:38.278Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:38.278Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test +2026-06-23T19:54:38.345Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:54:38.345Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T19:54:38.345Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:38.589Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:38.589Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T19:54:38.958Z [DEBUG] 1. [SYNC DEBUG] Hub file path for org jefeish-test1: .github/safe-settings/organizations/jefeish-test1 +2026-06-23T19:54:38.958Z [DEBUG] 3. [SYNC DEBUG] Org: jefeish-test1 +2026-06-23T19:54:38.958Z [DEBUG] 2. [SYNC DEBUG] Hub file branch/ref for org jefeish-test1: main +2026-06-23T19:54:39.199Z [DEBUG] 4. [SYNC DEBUG] Org orgFilesResp file names: README.md, repo.yml, settings.yml, suborg.yml +2026-06-23T19:54:39.199Z [DEBUG] 5a. [SYNC DEBUG] Fetching hub files for: + owner: jefeish-training, + repo: safe-settings-config-master, + path: .github/safe-settings/organizations/jefeish-test1, + ref: main +2026-06-23T19:54:39.199Z [DEBUG] 5. [SYNC DEBUG] Hub: jefeish-training +2026-06-23T19:54:39.423Z [ERROR] 6a. [SYNC DEBUG] Error fetching hub files: HttpError: Not Found - https://docs.github.com/rest/repos/contents#get-repository-content +2026-06-23T19:54:39.424Z [WARN] 6b. [SYNC DEBUG] File name mismatch for org jefeish-test1 +2026-06-23T22:06:42.092Z [DEBUG] repository.created payload from {"login":"safe-settings-decyjphr-emu[bot]","id":190884176,"node_id":"BOT_kgDOC2CpUA","avatar_url":"https://avatars.githubusercontent.com/u/84857889?v=4","gravatar_id":"","url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D","html_url":"https://github.com/apps/safe-settings-decyjphr-emu","followers_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/followers","following_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/repos","events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/safe-settings-decyjphr-emu%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false} +2026-06-23T22:06:42.093Z [DEBUG] deploymentConfig is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-23T22:06:42.622Z [DEBUG] config for ref undefined is {"restrictedRepos":["admin",".github","safe-settings"]} +2026-06-24T00:26:54.824Z [DEBUG] Check run was created! +2026-06-24T00:26:54.824Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T01:38:14.476Z [DEBUG] Check run was created! +2026-06-24T01:38:14.476Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T07:16:43.047Z [DEBUG] Check run was created! +2026-06-24T07:16:43.047Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:10:48.452Z [DEBUG] Check run was created! +2026-06-24T08:10:48.452Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:10:48.457Z [DEBUG] Check run was created! +2026-06-24T08:10:48.457Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:23:23.792Z [DEBUG] Check run was created! +2026-06-24T08:23:23.792Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:24:25.215Z [DEBUG] Check run was created! +2026-06-24T08:24:25.216Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:34:11.116Z [DEBUG] Check run was created! +2026-06-24T08:34:11.116Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:35:54.951Z [DEBUG] Not triggered by Safe-settings... +2026-06-24T08:35:54.950Z [DEBUG] Check run was created! +2026-06-24T09:14:22.165Z [DEBUG] Check run was created! +2026-06-24T09:14:22.165Z [DEBUG] Not triggered by Safe-settings... diff --git a/index.js b/index.js index e6fd1c8d7..39e56db9d 100644 --- a/index.js +++ b/index.js @@ -5,12 +5,23 @@ const cron = require('node-cron') const Glob = require('./lib/glob') const ConfigManager = require('./lib/configManager') const NopCommand = require('./lib/nopcommand') +const SettingsGenerator = require('./lib/settingsGenerator') const env = require('./lib/env') +const { setupRoutes } = require('./lib/routes') +const { initCache } = require('./lib/installationCache') +const { hubSyncHandler } = require('./lib/hubSyncHandler') let deploymentConfig module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => { let appSlug = 'safe-settings' + + // Initialize all routes (static UI + API) via centralized module + setupRoutes(robot, getRouter) + + // Initialize installation cache (env-controlled prefetch) + initCache(robot) + async function syncAllSettings (nop, context, repo = context.repo(), ref) { try { deploymentConfig = await loadYamlFileSystem() @@ -19,8 +30,21 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const runtimeConfig = await configManager.loadGlobalSettingsYaml() const config = Object.assign({}, deploymentConfig, runtimeConfig) robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`) + + // Load base branch config for NOP filtering (only show PR-introduced changes) + let baseConfig = null + if (nop && baseRef) { + try { + const baseConfigManager = new ConfigManager(context, baseRef) + const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml() + baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig) + } catch (e) { + robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`) + } + } + if (ref) { - return Settings.syncAll(nop, context, repo, config, ref) + return Settings.syncAll(nop, context, repo, config, ref, baseConfig, changedFiles) } else { return Settings.syncAll(nop, context, repo, config) } @@ -40,7 +64,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => } } - async function syncSubOrgSettings (nop, context, suborg, repo = context.repo(), ref) { + async function syncSettings (nop, context, repo = context.repo(), ref) { try { deploymentConfig = await loadYamlFileSystem() robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`) @@ -48,7 +72,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const runtimeConfig = await configManager.loadGlobalSettingsYaml() const config = Object.assign({}, deploymentConfig, runtimeConfig) robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`) - return Settings.syncSubOrgs(nop, context, suborg, repo, config, ref) + return Settings.sync(nop, context, repo, config, ref) } catch (e) { if (nop) { let filename = env.SETTINGS_FILE_PATH @@ -65,7 +89,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => } } - async function syncSettings (nop, context, repo = context.repo(), ref) { + async function syncSelectedSettings (nop, context, repos, subOrgs, ref, baseRef) { try { deploymentConfig = await loadYamlFileSystem() robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`) @@ -73,7 +97,20 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => const runtimeConfig = await configManager.loadGlobalSettingsYaml() const config = Object.assign({}, deploymentConfig, runtimeConfig) robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`) - return Settings.sync(nop, context, repo, config, ref) + + // Load base branch config for NOP filtering (only show PR-introduced changes) + let baseConfig = null + if (nop && baseRef) { + try { + const baseConfigManager = new ConfigManager(context, baseRef) + const baseRuntimeConfig = await baseConfigManager.loadGlobalSettingsYaml() + baseConfig = Object.assign({}, deploymentConfig, baseRuntimeConfig) + } catch (e) { + robot.log.debug(`Could not load base config for NOP filtering: ${e.message}`) + } + } + + return Settings.syncSelectedRepos(nop, context, repos, subOrgs, config, ref, baseConfig, baseRef) } catch (e) { if (nop) { let filename = env.SETTINGS_FILE_PATH @@ -81,9 +118,9 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => filename = env.DEPLOYMENT_CONFIG_FILE_PATH deploymentConfig = {} } - const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR') + const nopcommand = new NopCommand(filename, context.repo(), null, e, 'ERROR') robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`) - Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand) + Settings.handleError(nop, context, context.repo(), deploymentConfig, ref, nopcommand) } else { throw e } @@ -254,27 +291,29 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => return } + let repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner) + + let subOrgChanges = getAllChangedSubOrgConfigs(payload) + repoChanges = repoChanges.filter((r, i, arr) => arr.findIndex(item => item.repo === r.repo) === i) + + subOrgChanges = subOrgChanges.filter((s, i, arr) => arr.findIndex(item => item.repo === s.repo) === i) + robot.log.debug(`deduped repos ${JSON.stringify(repoChanges)}`) + robot.log.debug(`deduped subOrgs ${JSON.stringify(subOrgChanges)}`) + const settingsModified = payload.commits.find(commit => { return commit.added.includes(Settings.FILE_PATH) || commit.modified.includes(Settings.FILE_PATH) }) if (settingsModified) { robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`) - return syncAllSettings(false, context) - } - - const repoChanges = getAllChangedRepoConfigs(payload, context.repo().owner) - if (repoChanges.length > 0) { - return Promise.all(repoChanges.map(repo => { - return syncSettings(false, context, repo) - })) + return syncAllSettings(false, context, context.repo(), payload.after, null, { + repos: repoChanges, + subOrgs: subOrgChanges + }) } - const changes = getAllChangedSubOrgConfigs(payload) - if (changes.length) { - return Promise.all(changes.map(suborg => { - return syncSubOrgSettings(false, context, suborg) - })) + if (repoChanges.length > 0 || subOrgChanges.length > 0) { + return syncSelectedSettings(false, context, repoChanges, subOrgChanges, payload.after, payload.before) } robot.log.debug(`No changes in '${Settings.FILE_PATH}' detected, returning...`) @@ -521,6 +560,19 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref) }) + /** + * @description Handle pull_request.closed events to support hub synchronization + * @param {Object} context - The context object provided by Probot + */ + robot.on('pull_request.closed', async context => { + try { + await hubSyncHandler(robot, context) + } catch (err) { + robot.log.error(`pull_request.closed handler failed: ${err && err.message ? err.message : err}`) + } + return null + }) + robot.on(['check_suite.rerequested'], async context => { robot.log.debug('Check suite was rerequested!') return createCheckRun(context) @@ -572,35 +624,27 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => robot.log.debug(`Updating check run ${JSON.stringify(params)}`) await context.octokit.checks.update(params) - // guarding against null value from upstream libary that is - // causing a 404 and the check to stall - // from issue: https://github.com/github/safe-settings/issues/185#issuecomment-1075240374 - if (check_suite.before === '0000000000000000000000000000000000000000') { - check_suite.before = check_suite.pull_requests[0].base.sha - } - params = Object.assign(context.repo(), { basehead: `${check_suite.before}...${check_suite.after}` }) - const changes = await context.octokit.repos.compareCommitsWithBasehead(params) - const files = changes.data.files.map(f => { return f.filename }) + params = Object.assign(context.repo(), { pull_number: pull_request.number }) + + const changes = await context.octokit.pulls.listFiles(params) + const files = changes.data.map(f => { return f.filename }) const settingsModified = files.includes(Settings.FILE_PATH) + const repoChanges = getChangedRepoConfigName(files, context.repo().owner) + const subOrgChanges = getChangedSubOrgConfigName(files) if (settingsModified) { robot.log.debug(`Changes in '${Settings.FILE_PATH}' detected, doing a full synch...`) - return syncAllSettings(true, context, context.repo(), pull_request.head.ref) - } - - const repoChanges = getChangedRepoConfigName(files, context.repo().owner) - if (repoChanges.length > 0) { - return Promise.all(repoChanges.map(repo => { - return syncSettings(true, context, repo, pull_request.head.ref) - })) + const baseRef = pull_request.base.ref || repository.default_branch + return syncAllSettings(true, context, context.repo(), pull_request.head.ref, baseRef, { + repos: repoChanges, + subOrgs: subOrgChanges + }) } - const subOrgChanges = getChangedSubOrgConfigName(files) - if (subOrgChanges.length) { - return Promise.all(subOrgChanges.map(suborg => { - return syncSubOrgSettings(true, context, suborg, context.repo(), pull_request.head.ref) - })) + if (repoChanges.length > 0 || subOrgChanges.length > 0) { + const baseRef = pull_request.base.ref || repository.default_branch + return syncSelectedSettings(true, context, repoChanges, subOrgChanges, pull_request.head.ref, baseRef) } // if no safe-settings changes detected, send a success to the check run @@ -650,6 +694,137 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => return syncSettings(false, context) }) + /** + * Generate safe-settings YAML from the current state of a repo / org / + * collection-of-repos and open a PR against the admin repo with the result. + * + * @param {import('probot').Context} context + * @param {object} opts + * @param {'repo'|'org'|'custom-property'} opts.sourceType + * @param {string} opts.sourceValue + * @param {string} [opts.propertyName] + * @param {boolean} [opts.overwrite] + */ + async function generateSettings (context, opts) { + const owner = context.repo().owner + const github = context.octokit + const generator = new SettingsGenerator(github, owner, { log: robot.log }) + + const { filePath, yaml: content } = await generator.generate({ + sourceType: opts.sourceType, + sourceValue: opts.sourceValue, + propertyName: opts.propertyName + }) + + const targetPath = await resolveOutputPath(context, filePath, opts.overwrite) + return openSettingsPR(context, targetPath, content, opts) + } + + /** + * Honor the overwrite/.sample rule against the admin repo: if overwrite is + * false and the file already exists on the default branch, target a + * `.sample.yml` path instead. + */ + async function resolveOutputPath (context, filePath, overwrite) { + if (overwrite) return filePath + const { owner } = context.repo() + try { + await context.octokit.repos.getContent({ owner, repo: env.ADMIN_REPO, path: filePath }) + // File exists -> redirect to .sample + return filePath.replace(/(\.ya?ml)$/i, '.sample$1') + } catch (e) { + if (e.status === 404) return filePath + throw e + } + } + + /** + * Create a branch on the admin repo, commit the generated file, and open a PR. + */ + async function openSettingsPR (context, filePath, content, opts) { + const github = context.octokit + const { owner } = context.repo() + const repo = env.ADMIN_REPO + + const repoInfo = await github.repos.get({ owner, repo }) + const baseBranch = repoInfo.data.default_branch + const baseRef = await github.git.getRef({ owner, repo, ref: `heads/${baseBranch}` }) + const branchName = `safe-settings-generate/${opts.sourceType}-${opts.sourceValue}-${Date.now()}`.replace(/[^a-zA-Z0-9/_.-]/g, '-') + + await github.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseRef.data.object.sha + }) + + let existingSha + try { + const existing = await github.repos.getContent({ owner, repo, path: filePath, ref: branchName }) + existingSha = existing.data.sha + } catch (e) { + if (e.status !== 404) throw e + } + + await github.repos.createOrUpdateFileContents({ + owner, + repo, + path: filePath, + branch: branchName, + message: `Generate ${filePath} from current ${opts.sourceType} settings`, + content: Buffer.from(content).toString('base64'), + sha: existingSha + }) + + const pr = await github.pulls.create({ + owner, + repo, + title: `Generate safe-settings config for ${opts.sourceType}: ${opts.sourceValue}`, + head: branchName, + base: baseBranch, + body: [ + `Auto-generated safe-settings configuration from the current state of \`${opts.sourceType}\` \`${opts.sourceValue}\`.`, + '', + `- File: \`${filePath}\``, + `- Overwrite: \`${!!opts.overwrite}\``, + '', + 'Review carefully before merging. Run in nop mode to confirm there are no unexpected diffs.' + ].join('\n') + }) + + robot.log.info(`Opened settings-generation PR #${pr.data.number} (${filePath})`) + return pr.data + } + + // Trigger generation via a repository_dispatch event: + // event_type: safe-settings-generate + // client_payload: { source_type, source_value, overwrite, property_name? } + robot.on('repository_dispatch', async context => { + const { payload } = context + if (payload.action !== 'safe-settings-generate') { + robot.log.debug(`Ignoring repository_dispatch action "${payload.action}"`) + return + } + const cp = payload.client_payload || {} + const sourceType = cp.source_type + const sourceValue = cp.source_value + if (!sourceType || !sourceValue) { + robot.log.error('repository_dispatch safe-settings-generate requires source_type and source_value') + return + } + try { + return await generateSettings(context, { + sourceType, + sourceValue, + propertyName: cp.property_name, + overwrite: cp.overwrite === true || cp.overwrite === 'true' + }) + } catch (e) { + robot.log.error(`Failed to generate settings: ${e.stack || e}`) + throw e + } + }) + if (process.env.CRON) { /* # ┌────────────── second (optional) @@ -672,6 +847,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => info() return { - syncInstallation + syncInstallation, + generateSettings } } diff --git a/lib/commentmessage.js b/lib/commentmessage.js index b54f81bb4..dc932402b 100644 --- a/lib/commentmessage.js +++ b/lib/commentmessage.js @@ -1,31 +1,38 @@ -module.exports = `* Run on: \` <%= new Date() %> \` +module.exports = `<% const esc = s => String(s).replace(/&/g, "&").replace(//g, ">") %>Run on: \`<%= new Date().toISOString() %>\` -* Number of repos that were considered: \`<%= Object.keys(it.reposProcessed).length %> \` +* Number of repos considered: \`<%= Object.keys(it.reposProcessed).length %>\` +* Number of repos affected: \`<%= it.reposAffected || 0 %>\` ### Breakdown of changes -| Repo <% Object.keys(it.changes).forEach(plugin => { %> | <%= plugin %> settings <% }) %> | -| -- <% Object.keys(it.changes).forEach(plugin => { -%> | -- <% }) %> -| -<% Object.keys(it.reposProcessed).forEach( repo => { -%> -| <%= repo -%> - <%- Object.keys(it.changes).forEach(plugin => { -%> - <%_ if (it.changes[plugin][repo]) { -%> | :hand: <% } else { %> | :grey_exclamation: <% } -%> - <%_ }) -%> | -<% }) -%> - -:hand: -> Changes to be applied to the GitHub repository. -:grey_exclamation: -> nothing to be changed in that particular GitHub repository. + +<% if (!it.checkRunDetails || it.checkRunDetails.length === 0) { %> +No changes to apply. +<% } else { %> +<%~ it.checkRunDetails %> +<% } %> ### Breakdown of errors <% if (Object.keys(it.errors).length === 0) { %> \`None\` <% } else { %> - <% Object.keys(it.errors).forEach(repo => { %> - <%_= repo %>: - <% it.errors[repo].forEach(plugin => { %> - * <%= plugin.msg %> - <% }) %> +
+:warning: Errors by repo — <%= Object.keys(it.errors).length %> repo(s) affected + +<%~ Object.keys(it.errors).map(repo => "**" + esc(repo) + "**:\\n" + it.errors[repo].map(err => "* " + esc(err.msg)).join("\\n")).join("\\n\\n") %> + +
+<% } %> + +### Informational messages + +<% if (!it.infos || Object.keys(it.infos).length === 0) { %> +\`None\` +<% } else { %> +
+:information_source: Info — <%= Object.keys(it.infos).length %> repo(s) + +<%~ Object.keys(it.infos).map(repo => "**" + esc(repo) + "**:\\n" + it.infos[repo].map(msg => "* ℹ️ " + esc(msg)).join("\\n")).join("\\n\\n") %> - <% }) %> +
<% } %>` diff --git a/lib/configManager.js b/lib/configManager.js index 58f5bb436..14f68826f 100644 --- a/lib/configManager.js +++ b/lib/configManager.js @@ -19,9 +19,12 @@ module.exports = class ConfigManager { try { const repo = { owner: this.context.repo().owner, repo: env.ADMIN_REPO } const params = Object.assign(repo, { path: filePath, ref: this.ref }) - const response = await this.context.octokit.repos.getContent(params).catch(e => { - this.log.error(`Error getting settings ${e}`) - }) + const response = await this.context.octokit.repos.getContent(params) + + // Return null if response is undefined (error occurred) + if (!response || !response.data) { + return null + } // Ignore in case path is a folder // - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-directory diff --git a/lib/env.js b/lib/env.js index 94c0ea742..91cbb5984 100644 --- a/lib/env.js +++ b/lib/env.js @@ -1,10 +1,22 @@ module.exports = { ADMIN_REPO: process.env.ADMIN_REPO || 'admin', + SAFE_SETTINGS_HUB_REPO: process.env.SAFE_SETTINGS_HUB_REPO || 'admin-master', + SAFE_SETTINGS_HUB_ORG: process.env.SAFE_SETTINGS_HUB_ORG || 'admin-master-org', + SAFE_SETTINGS_HUB_DIRECT_PUSH: process.env.SAFE_SETTINGS_HUB_DIRECT_PUSH || 'false', + SAFE_SETTINGS_HUB_PATH: process.env.SAFE_SETTINGS_HUB_PATH || '.github/safe-settings', + APP_ID: process.env.APP_ID || null, + PRIVATE_KEY_PATH: process.env.PRIVATE_KEY_PATH || 'private-key.pem', CONFIG_PATH: process.env.CONFIG_PATH || '.github', SETTINGS_FILE_PATH: process.env.SETTINGS_FILE_PATH || 'settings.yml', DEPLOYMENT_CONFIG_FILE_PATH: process.env.DEPLOYMENT_CONFIG_FILE || 'deployment-settings.yml', CREATE_PR_COMMENT: process.env.CREATE_PR_COMMENT || 'true', CREATE_ERROR_ISSUE: process.env.CREATE_ERROR_ISSUE || 'true', BLOCK_REPO_RENAME_BY_HUMAN: process.env.BLOCK_REPO_RENAME_BY_HUMAN || 'false', - FULL_SYNC_NOP: process.env.FULL_SYNC_NOP === 'true' + FULL_SYNC_NOP: process.env.FULL_SYNC_NOP === 'true', + SAFE_SETTINGS_HUB_URL_PREFIX: (() => { + const prefix = process.env.SAFE_SETTINGS_HUB_URL_PREFIX || '/safe-settings' + // Normalize: add leading '/' if missing, treat '/' as empty string for root path + if (!prefix || prefix === '/') return '' + return prefix.startsWith('/') ? prefix : `/${prefix}` + })() } diff --git a/lib/hubSyncHandler.js b/lib/hubSyncHandler.js new file mode 100644 index 000000000..1753e3ff9 --- /dev/null +++ b/lib/hubSyncHandler.js @@ -0,0 +1,901 @@ +const { minimatch } = require('minimatch') +const env = require('./env') +const { getInstallations } = require('./installationCache') +const yaml = require('js-yaml') +const path = require('path') +const fs = require('fs') +const os = require('os') +const util = require('util') +const mergeBy = require('./mergeArrayBy') + +/** + * Attach a file-backed logger to robot.log that mirrors all log calls to a file. + * It preserves the original behavior and appends each log line to a file, trimming + * the file to the last `maxLines` entries (default 1000). + * + * Usage: call attachFileLogger(robot, { filePath: '/tmp/safe-settings.log', maxLines: 1000 }) + */ +function attachFileLogger (robot, options = {}) { + if (!robot || !robot.log) return + if (robot.log.__fileLoggerAttached) return + const filePath = options.filePath || process.env.SAFE_SETTINGS_LOG_FILE || path.join(process.cwd(), 'hubSyncHandler.log') + const maxLines = Number(options.maxLines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || 1000) + const methods = ['info', 'warn', 'debug', 'error', 'fatal', 'trace', 'notice'] + + methods.forEach(method => { + const orig = (robot.log && robot.log[method]) ? robot.log[method].bind(robot.log) : (...args) => { /* no-op */ } + robot.log[method] = (...args) => { + // call original logger so console output still occurs + try { orig(...args) } catch (e) { /* swallow */ } + + // Build a single-line message representation + try { + const msg = args.map(a => (typeof a === 'string' ? a : util.inspect(a, { depth: 2 }))).join(' ') + const line = `${new Date().toISOString()} [${method.toUpperCase()}] ${msg}` + // append and then trim to last `maxLines` + fs.appendFile(filePath, line + os.EOL, err => { + if (err) { + try { orig(`Failed to append log to ${filePath}: ${err.message}`) } catch (e) { /* swallow */ } + return + } + // trim asynchronously + fs.promises.readFile(filePath, 'utf8').then(data => { + const lines = data.split(/\r?\n/) + // Remove a possible trailing empty line created by join + if (lines.length && lines[lines.length - 1] === '') lines.pop() + if (lines.length > maxLines) { + const tail = lines.slice(-maxLines) + return fs.promises.writeFile(filePath, tail.join(os.EOL) + os.EOL, 'utf8') + } + return Promise.resolve() + }).catch(() => { /* don't break logging on trim failures */ }) + }) + } catch (e) { + try { orig(`Failed to write log to ${filePath}: ${e && e.message ? e.message : e}`) } catch (e) { /* swallow */ } + } + } + }) + + robot.log.__fileLoggerAttached = true +} + +/** + * Get authenticated octokit client for an org installation + * @param {import('probot').Probot} robot + * @param {string} orgName + * @returns {Promise} Authenticated client or null + */ +async function getOrgInstallation (robot, orgName) { + const installs = await getInstallations(robot) + const install = installs.find(i => i.account && i.account.type === 'Organization' && i.account.login.toLowerCase() === orgName.toLowerCase()) + if (!install) { + return null + } + return await robot.auth(install.id) +} + + +// Helper to create a branch if not direct push +async function createBranchIfNeeded(githubClient, owner, repo, baseBranch, branchName, directPush, logger) { + if (!directPush) { + try { + const baseRef = await githubClient.rest.git.getRef({ owner, repo, ref: `heads/${baseBranch}` }) + const baseSha = baseRef.data.object.sha + await githubClient.rest.git.createRef({ owner, repo, ref: `refs/heads/${branchName}`, sha: baseSha }) + logger.info(`Created branch ${branchName} in ${owner}/${repo}`) + } catch (err) { + if (err.status === 422) { + logger.warn(`Branch ${branchName} already exists, continuing`) + } else { + throw err + } + } + } +} + +// Helper to create or update a file in a repo +async function createOrUpdateFile(githubClient, params, logger) { + try { + await githubClient.rest.repos.createOrUpdateFileContents(params) + logger.info(`Committed ${params.path} to ${params.owner}/${params.repo}@${params.branch}`) + } catch (err) { + logger.error(`Failed to sync file ${params.path}: ${err.message}`) + throw err + } +} + +/** + * Sync changed safe-settings organization files from the master admin PR + * into the target organization's admin repository. + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + * @param {string} orgName Destination organization login (also folder name under organizations/) + * @param {string} destRepo Destination repo name inside orgName (e.g. admin repo) + * @param {string} destinationFolder Base folder in destination repo where content lives (e.g. .github or .github/safe-settings) + */ +async function syncHubOrgUpdate (robot, context, orgName, destRepo, destinationFolder) { + attachFileLogger(robot) + try { + robot.log.info(`Syncing safe settings for organization: ${orgName}`) + robot.log.info(`Organization: ${orgName}, Destination Repo: ${destRepo}, Destination Folder: ${destinationFolder}`) + const pr = context.payload.pull_request + if (!pr) { + robot.log.warn('No pull_request payload found; aborting sync') + return + } + const { owner: srcOwner, repo: srcRepo } = context.repo() + const pull_number = pr.number + const configRoot = env.CONFIG_PATH || '.github/' + const sourceBase = (`${configRoot}/${env.SAFE_SETTINGS_HUB_PATH}/organizations`).replace(/\/$/, '') + robot.log.debug(`sourceBase='${sourceBase}'`) + robot.log.debug(`env.CONFIG_PATH='${env.CONFIG_PATH}', env.SAFE_SETTINGS_HUB_PATH='${env.SAFE_SETTINGS_HUB_PATH}'`) + const files = await context.octokit.paginate( + context.octokit.rest.pulls.listFiles, + { owner: srcOwner, repo: srcRepo, pull_number, per_page: 100 } + ) + robot.log.debug(`PR #${pull_number} contains ${files.length} changed file(s)`) + if (files.length) robot.log.debug(`files=${files.map(f => f.filename).join(', ')}`) + if (files.length) { + try { + robot.log.debug(`first file object = ${JSON.stringify(files[0], null, 2)}`) + robot.log.debug(`file[0] keys = ${Object.keys(files[0] || {}).join(', ')}`) + } catch (e) { + robot.log.debug(`failed to stringify first file: ${e.message}`) + } + files.forEach((f, i) => { + try { + robot.log.debug(`FILE[${i}] raw=${JSON.stringify(f)}`) + robot.log.debug(`FILE[${i}] filename=${JSON.stringify(f.filename)} length=${(f.filename || '').length}`) + } catch (e) { + robot.log.debug(`FILE[${i}] stringify error: ${e.message}`) + } + }) + } + const orgPrefix = `${sourceBase}/${orgName}/` + robot.log.debug(`files=${files.map(f => f.filename).join(', ')}`) + robot.log.debug(`Path ${sourceBase}/${orgName}`) + const relevant = files.filter(f => f.filename === `${sourceBase}/${orgName}` || f.filename.startsWith(orgPrefix)) + robot.log.debug(`Found ${relevant.length} changed file(s) relevant to org ${orgName}`) + if (!relevant.length) { + robot.log.info(`No files for org ${orgName} in PR #${pull_number}`) + files.forEach(f => { + const exact = f.filename === `${sourceBase}/${orgName}` + const pref = f.filename.startsWith(orgPrefix) + robot.log.info(`MATCH CHECK: file='${f.filename}' exact=${exact} prefix=${pref}`) + }) + const altBase = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/organizations` + const altPrefix = `${altBase}/${orgName}/` + files.forEach(f => { + const exactAlt = f.filename === `${altBase}/${orgName}` + const prefAlt = f.filename.startsWith(altPrefix) + robot.log.info(`ALT CHECK: file='${f.filename}' exactAlt=${exactAlt} prefAlt=${prefAlt}`) + }) + return + } + const destOwner = orgName + const destBase = (destinationFolder || env.CONFIG_PATH || '.github').replace(/\/$/, '') + const destBaseBranch = 'main' + const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1') + const githubDest = await getOrgInstallation(robot, destOwner) + if (!githubDest) { + robot.log.warn(`Installation for destination org ${destOwner} not found; cannot sync`) + return + } + robot.log.info(`Syncing from ${srcOwner}/${srcRepo} PR #${pull_number} to ${destOwner}/${destRepo}@${destBaseBranch} under ${destBase} (directPush=${directPush})`) + const timestamp = Date.now() + const branchName = directPush ? destBaseBranch : `safe-settings-sync/pr-${pull_number}-${orgName}-${timestamp}` + await createBranchIfNeeded(githubDest, destOwner, destRepo, destBaseBranch, branchName, directPush, robot.log) + for (const f of relevant) { + let relative + if (f.filename === `${sourceBase}/${orgName}`) { + continue + } else { + relative = f.filename.slice(orgPrefix.length) + } + const destPath = `${destBase}/${relative}`.replace(/\/+/g, '/') + const srcContentResp = await context.octokit.rest.repos.getContent({ owner: srcOwner, repo: srcRepo, path: f.filename, ref: pr.head.sha }) + const data = srcContentResp.data + if (Array.isArray(data)) { + continue + } + const fileContent = Buffer.from(data.content, data.encoding).toString('utf8') + const encoded = Buffer.from(fileContent, 'utf8').toString('base64') + let existingSha + try { + const destGet = await githubDest.rest.repos.getContent({ owner: destOwner, repo: destRepo, path: destPath, ref: destBaseBranch }) + if (!Array.isArray(destGet.data)) existingSha = destGet.data.sha + } catch (getErr) { + if (getErr.status !== 404) throw getErr + } + await createOrUpdateFile(githubDest, { + owner: destOwner, + repo: destRepo, + path: destPath, + message: directPush ? `Direct sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}` : `Sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}`, + content: encoded, + branch: branchName, + sha: existingSha, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }, robot.log) + } + if (!directPush) { + try { + const prTitle = `Sync safe-settings from ${srcOwner}/${srcRepo} PR #${pull_number}` + const prBody = `Automated sync of safe-settings for ${orgName} from ${srcOwner}/${srcRepo} PR #${pull_number}.` + const created = await githubDest.rest.pulls.create({ owner: destOwner, repo: destRepo, title: prTitle, head: branchName, base: destBaseBranch, body: prBody }) + robot.log.info(`Created PR ${created.data.html_url} in ${destOwner}/${destRepo}`) + } catch (prErr) { + robot.log.error(`Failed to create PR in ${destOwner}/${destRepo}: ${prErr.message}`) + throw prErr + } + } else { + robot.log.info(`Changes pushed directly to ${destOwner}/${destRepo}@${destBaseBranch}`) + } + } catch (err) { + robot.log.error(`syncSafeSettingConfig error for org ${orgName}: ${err.message}`) + } +} + +/** + * Handle closed pull requests to sync safe-settings changes to target organizations. + * Focus on the organization and repository specified in the pull request and if they belong to the Safe-Settings Hub. + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + */ +async function hubSyncHandler (robot, context) { + attachFileLogger(robot) + const { payload } = context + const { repository, pull_request } = payload || {} + robot.log.info(`Received 'pull_request.closed' event: ${pull_request && pull_request.number}`) + try { + // Ensure the event is from the configured Safe-Settings Hub repo/org + const isMasterRepo = repository && repository.name === env.SAFE_SETTINGS_HUB_REPO + const isMasterOrg = repository && repository.owner && repository.owner.login === env.SAFE_SETTINGS_HUB_ORG + + if (!(isMasterRepo && isMasterOrg)) { + robot.log.info(`Pull request.closed is not from master admin repo/org (${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO}), ignoring`) + return + } + + robot.log.info(`Pull request closed on Safe-Settings Hub: (${repository.full_name})`) + + // Get the PR details + const pr = pull_request + const { owner, repo } = context.repo() + const pull_number = pr.number + + // Paginate through all files changed in the PR + const files = await context.octokit.paginate( + context.octokit.rest.pulls.listFiles, + { owner, repo, pull_number, per_page: 100 } + ) + + robot.log.info(`Files changed in PR #${pull_number}: ${files.map(f => f.filename).join(', ')}`) + + // Routing logic: check for 'globals' or 'organizations' folder changes + const globalsChanged = files.some(f => /\/globals\//.test(f.filename)) + const orgsChanged = files.some(f => /\/organizations\//.test(f.filename)) + + if (globalsChanged) { + robot.log.debug('Detected changes in the globals folder. Routing to syncHubGlobalsUpdate(...).') + await module.exports.syncHubGlobalsUpdate(robot, context, files) + } + + if (orgsChanged) { + robot.log.debug('Detected changes in the organizations folder. Routing to syncHubOrgUpdate(...).') + // Only sync updates in organization subfolders, not files directly in organizations folder + const baseSettingsPath = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}/organizations` + const normalizedBase = baseSettingsPath.replace(/\/$/, '') + const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Only match files in org subfolders: .../organizations//... + const orgSubfolderPattern = new RegExp(`^${escapeRegex(normalizedBase)}/([^/]+)/.+`) + const orgNamesSet = new Set() + files.forEach(f => { + const m = f.filename.match(orgSubfolderPattern) + if (m && m[1]) { + orgNamesSet.add(m[1]) + } + }) + const orgNames = Array.from(orgNamesSet) + robot.log.info(`Orgs updated in PR #${pull_number}: ${orgNames.join(', ')}`) + for (const orgName of orgNames) { + const destRepo = env.ADMIN_REPO + const destinationFolder = env.CONFIG_PATH || '.github' + await module.exports.syncHubOrgUpdate(robot, context, orgName, destRepo, destinationFolder) + } + } + } catch (err) { + robot.log.error(`Failed to sync safe settings: ${err && err.message ? err.message : err}`) + } +} + +/** + * Handle updates in the globals folder and sync to destinations defined in manifest.yml rules + * @param {import('probot').Probot} robot + * @param {import('probot').Context} context + * @param {Array} files - Array of changed file objects from PR + */ +async function syncHubGlobalsUpdate (robot, context, files) { + attachFileLogger(robot) + robot.log.info(`Syncing safe settings for 'globals/'.`) + const manifestPath = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/globals/manifest.yml` + let manifest + try { + const resp = await context.octokit.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: manifestPath, + ref: 'main' + }) + const manifestContent = Buffer.from(resp.data.content, resp.data.encoding).toString('utf8') + manifest = yaml.load(manifestContent) + robot.log.debug('Loaded manifest.yml rules from hub repo:' + JSON.stringify(manifest, null, 2)) + } catch (err) { + robot.log.error('Failed to load manifest.yml from hub repo:' + err.message) + return + } + const changedGlobals = files.filter(f => /\/globals\//.test(f.filename)) + if (!changedGlobals.length) { + robot.log.info('No changed files in globals folder.') + return + } + // Pre-filter rules for each file, and precompute orgs for each rule + const installs = await getInstallations(robot) + const orgLogins = installs.filter(i => i.account && i.account.type === 'Organization').map(i => i.account.login) + // Precompute matching rules for each fileName in changedGlobals + const fileNameToMatchingRules = {}; + for (const fileObj of changedGlobals) { + const fileName = fileObj.filename.split('/').pop(); + fileNameToMatchingRules[fileName] = (manifest.rules || []).filter(rule => + (rule.files || []).some(pattern => minimatch(fileName, pattern)) + ); + } + for (const fileObj of changedGlobals) { + const fileName = fileObj.filename.split('/').pop(); + if (fileName === 'manifest.yml') { + robot.log.debug(`Skipping sync for manifest.yml (should only exist in hub)`); + continue; + } + robot.log.debug(`Evaluating globals file: ${fileObj.filename}`); + // Use precomputed matching rules + const matchingRules = fileNameToMatchingRules[fileName]; + for (const rule of matchingRules) { + const mergeStrategy = rule.mergeStrategy || 'merge'; + // Precompute orgs to sync for each target pattern + let orgsToSync = []; + for (const orgPattern of rule.targets || []) { + if (orgPattern === '*') { + orgsToSync.push(...orgLogins); + } else if (orgPattern.endsWith('*')) { + const prefix = orgPattern.slice(0, -1); + orgsToSync.push(...orgLogins.filter(login => login.startsWith(prefix))); + } else { + orgsToSync.push(orgPattern); + } + } + // Remove duplicates + orgsToSync = Array.from(new Set(orgsToSync)); + robot.log.debug(`Rule '${rule.name}' matches file '${fileName}'. Targets: ${orgsToSync.join(', ')}`); + for (const orgName of orgsToSync) { + robot.log.debug(`Preparing to sync file '${fileName}' to org '${orgName}' with mergeStrategy='${mergeStrategy}'`); + const destRepo = env.ADMIN_REPO; + const githubDest = await getOrgInstallation(robot, orgName); + if (!githubDest) { + robot.log.info(`Skipping org ${orgName}: no installation found.`); + continue; + } + let repoExists = false; + try { + await githubDest.repos.get({ owner: orgName, repo: destRepo }); + repoExists = true; + } catch (err) { + if (err.status === 404) { + robot.log.info(`Skipping org ${orgName}: config repo '${destRepo}' does not exist.`); + continue; + } else { + throw err; + } + } + if (!repoExists) continue; + const destPath = `${env.CONFIG_PATH}/${fileName}`; + let exists = false; + let existingSha = undefined; + try { + robot.log.debug(`Checking existence of ${destPath} in ${orgName}/${destRepo}`); + const resp = await githubDest.repos.getContent({ + owner: orgName, + repo: destRepo, + path: destPath, + ref: 'main' + }); + if (!Array.isArray(resp.data)) { + robot.log.debug(`Found ${destPath} in ${orgName}/${destRepo}`); + exists = true; + existingSha = resp.data.sha; + } + } catch (err) { + if (err.status === 404) { + robot.log.info(`File ${destPath} not found in ${orgName}/${destRepo} (this is fine for both merge strategies)`); + exists = false; + existingSha = undefined; + } else { + robot.log.error(`Error checking ${destPath} in ${orgName}/${destRepo}: ${err.message}`); + throw err; + } + } + if (mergeStrategy === 'merge' && exists) { + robot.log.info(`Skipping sync of ${fileName} to ${orgName} (already exists & mergeStrategy=${mergeStrategy})`); + continue; + } + robot.log.info(`Syncing ${fileName} to ${orgName} (mergeStrategy=${mergeStrategy})`); + try { + let srcContentResp; + const pr = context.payload && context.payload.pull_request; + const srcRef = pr && pr.head && pr.head.sha ? pr.head.sha : 'main'; + srcContentResp = await context.octokit.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fileObj.filename, + ref: srcRef + }); + const data = srcContentResp.data; + if (Array.isArray(data)) { + robot.log.debug(`Skipping directory ${fileObj.filename}`); + continue; + } + const fileContent = Buffer.from(data.content, data.encoding).toString('utf8'); + const encoded = Buffer.from(fileContent, 'utf8').toString('base64'); + const destBaseBranch = 'main'; + const directPush = (env.SAFE_SETTINGS_HUB_DIRECT_PUSH === 'true' || env.SAFE_SETTINGS_HUB_DIRECT_PUSH === '1'); + const timestamp = Date.now(); + const branchName = directPush ? destBaseBranch : `safe-settings-globals-sync/${orgName}-${fileName}-${timestamp}`; + await createBranchIfNeeded(githubDest, orgName, destRepo, destBaseBranch, branchName, directPush, robot.log); + await createOrUpdateFile(githubDest, { + owner: orgName, + repo: destRepo, + path: destPath, + message: directPush ? `Direct sync globals file '${fileName}' from hub` : `Sync globals file '${fileName}' from hub`, + content: encoded, + branch: branchName, + sha: exists ? existingSha : undefined, + committer: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' }, + author: { name: 'Safe Settings Bot', email: 'safe-settings-bot@example.com' } + }, robot.log); + if (!directPush) { + try { + const prTitle = `Sync globals file '${fileName}' from hub`; + const prBody = `Automated sync of globals file '${fileName}' from hub to ${orgName}.`; + const created = await githubDest.rest.pulls.create({ owner: orgName, repo: destRepo, title: prTitle, head: branchName, base: destBaseBranch, body: prBody }); + robot.log.info(`Created PR ${created.data.html_url} in ${orgName}/${destRepo}`) + } catch (prErr) { + robot.log.error(`Failed to create PR in ${orgName}/${destRepo}: ${prErr.message}`) + throw prErr + } + } else { + robot.log.info(`Changes pushed directly to ${orgName}/${destRepo}@${destBaseBranch}`) + } + } catch (syncErr) { + robot.log.error(`Failed to sync globals file ${fileName} to ${orgName}: ${syncErr.message}`) + } + } + } + } +} + +/** + * Retrieve settings files from remote organization admin repositories, + * commit them into a branch in the hub repository, and open a pull request. + * @param {import('probot').Probot} robot + * @param {Array} orgNames Array of organization names to retrieve settings from + * @param {Object} options Options for the operation + * @param {string} options.baseBranch Base branch to create new branches from (default: 'main') + * @returns {Promise>} Results of the operation for each organization + */ +async function retrieveSettingsFromOrgs (robot, orgNames = [], options = {}) { + attachFileLogger(robot) + const results = [] + try { + if (!Array.isArray(orgNames) || orgNames.length === 0) return results + + const installs = await getInstallations(robot) + + const hubOwnerLogin = (env.SAFE_SETTINGS_HUB_ORG || '').toLowerCase() + const hubRepoName = env.SAFE_SETTINGS_HUB_REPO + if (!hubOwnerLogin || !hubRepoName) { + throw new Error('SAFE_SETTINGS_HUB_ORG and SAFE_SETTINGS_HUB_REPO must be configured') + } + + const hubInstall = installs.find(i => i.account && i.account.login && i.account.login.toLowerCase() === hubOwnerLogin) + if (!hubInstall) throw new Error(`Installation for hub org ${env.SAFE_SETTINGS_HUB_ORG} not found`) + + const githubHub = await robot.auth(hubInstall.id) + const baseBranch = options.baseBranch || 'main' + + // Resolve the base sha for creating branches + let baseRef, baseSha + try { + baseRef = await githubHub.rest.git.getRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `heads/${baseBranch}` }) + baseSha = baseRef.data && baseRef.data.object && baseRef.data.object.sha + } catch (refErr) { + if (refErr && refErr.status === 404) { + // Hub repo doesn't exist - return N/A for all requested orgs + robot.log.warn(`Hub repository ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName} or branch '${baseBranch}' not found`) + return orgNames.map(org => ({ org, status: 'N/A', reason: `hub_repo_not_found: ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName}` })) + } + throw refErr + } + + // Helper: collect all files under a path in a repo (recursively) + async function collectFilesFromRepo (githubClient, owner, repo, dirPath, ref = 'main') { + const out = [] + + // First verify the repo exists by checking for the ref + try { + await githubClient.rest.git.getRef({ owner, repo, ref: `heads/${ref}` }) + } catch (repoCheckErr) { + if (repoCheckErr && repoCheckErr.status === 404) { + const err404 = new Error(`Repository ${owner}/${repo} or branch '${ref}' not found`) + err404.status = 404 + throw err404 + } + throw repoCheckErr + } + + async function walk (p) { + try { + const resp = await githubClient.repos.getContent({ owner, repo, path: p, ref }) + const data = resp.data + if (Array.isArray(data)) { + for (const item of data) { + if (item.type === 'file') { + try { + const fileResp = await githubClient.repos.getContent({ owner, repo, path: item.path, ref }) + if (!Array.isArray(fileResp.data) && typeof fileResp.data.content === 'string') { + const decoded = Buffer.from(fileResp.data.content, fileResp.data.encoding || 'base64').toString('utf8') + out.push({ path: fileResp.data.path, content: decoded }) + } + } catch (fe) { + // skip unreadable files, but log + robot.log && robot.log.warn && robot.log.warn(`collectFilesFromRepo: failed to fetch ${item.path} from ${owner}/${repo}: ${fe.message}`) + } + } else if (item.type === 'dir') { + await walk(item.path) + } else { + // skip other types (submodules, symlinks) + robot.log && robot.log.debug && robot.log.debug(`Skipping unsupported item type ${item.type} at ${item.path}`) + } + } + } else if (typeof data.content === 'string') { + const decoded = Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + out.push({ path: data.path, content: decoded }) + } + } catch (e) { + if (e && e.status === 404) { + // path does not exist on repo -> no files + return + } + throw e + } + } + await walk(dirPath) + return out + } + + // Iterate requested orgs and import their CONFIG_PATH into the hub repo under the organizations/ tree + for (const orgName of orgNames) { + try { + if (!orgName) { results.push({ org: orgName, error: 'invalid org name' }); continue } + robot.log.info(`Retrieving settings from org: ${orgName}`) + + // fast existence check on the hub repo: skip if org folder already exists under CONFIG_PATH/SAFE_SETTINGS_HUB_PATH/organizations + try { + const destOrgPath = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${orgName}` + try { + const destCheck = await githubHub.rest.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, path: destOrgPath, ref: baseBranch }) + if (Array.isArray(destCheck.data) && destCheck.data.length > 0) { + robot.log.info(`Skipping ${orgName}: already present in hub`) + results.push({ org: orgName, status: 'imported', reason: 'already_imported' }) + continue + } + } catch (probeErr) { + if (!(probeErr && probeErr.status === 404)) { + robot.log && robot.log.warn && robot.log.warn(`Failed to probe hub destination for ${orgName}: ${probeErr.message}`) + results.push({ org: orgName, error: `failed to check destination: ${probeErr.message}` }) + continue + } + + // 404 -> not present, proceed + } + } catch (e) { + robot.log && robot.log.warn && robot.log.warn(`Unexpected error while probing destination for ${orgName}: ${e.message}`) + results.push({ org: orgName, error: `probe error: ${e.message}` }) + continue + } + + const srcInstall = installs.find(i => i.account && i.account.login && i.account.login.toLowerCase() === orgName.toLowerCase()) + if (!srcInstall) { + results.push({ org: orgName, error: 'installation not found for org' }) + continue + } + + const githubSrc = await robot.auth(srcInstall.id) + const adminRepo = env.ADMIN_REPO + if (!adminRepo) { + results.push({ org: orgName, error: 'ADMIN_REPO is not configured' }) + continue + } + + const sourceBase = (env.CONFIG_PATH || '.github').replace(/\/$/, '') + // collect files from the source admin repo under CONFIG_PATH + let files + try { + files = await collectFilesFromRepo(githubSrc, orgName, adminRepo, sourceBase, 'main') + } catch (collectErr) { + if (collectErr && collectErr.status === 404) { + robot.log.info(`Skipping ${orgName}: admin repo '${adminRepo}' not found`) + results.push({ org: orgName, status: 'N/A', reason: `admin_repo_not_found: ${adminRepo}` }) + continue + } + throw collectErr + } + + if (!files || files.length === 0) { + results.push({ org: orgName, status: 'N/A', reason: 'no_files_at_config_path' }) + continue + } + + const timestamp = Date.now() + const branchName = `safe-settings-import/${orgName}/${timestamp}`.replace(/[^a-zA-Z0-9_\-./]/g, '-') + + // create branch in hub repo + try { + await githubHub.rest.git.createRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `refs/heads/${branchName}`, sha: baseSha }) + } catch (createErr) { + if (createErr && createErr.status === 422) { + robot.log.info(`Branch ${branchName} already exists, continuing`) // continue + } else { + throw createErr + } + } + + // Instead of creating/updating files one-by-one, build a single tree and commit so the PR contains all files atomically + try { + const treeEntries = [] + for (const f of files) { + // relative path under the sourceBase + const rel = path.posix.relative(sourceBase, f.path) + // Destination should be: CONFIG_PATH/SAFE_SETTINGS_HUB_PATH/organizations// + const destBase = `${(env.CONFIG_PATH || '.github').replace(/\/$/, '')}/${env.SAFE_SETTINGS_HUB_PATH}` + const destPath = path.posix.join(destBase, 'organizations', orgName, rel).replace(/\/+/g, '/') + treeEntries.push({ path: destPath, mode: '100644', type: 'blob', content: f.content }) + } + + // Get base commit and tree + const baseCommitResp = await githubHub.rest.git.getCommit({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, commit_sha: baseSha }) + const baseTreeSha = baseCommitResp.data && baseCommitResp.data.tree && baseCommitResp.data.tree.sha + + // Create a new tree rooted at the base tree + const createdTree = await githubHub.rest.git.createTree({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, tree: treeEntries, base_tree: baseTreeSha }) + + // Create a commit that points to the new tree + const commitMessage = `Import safe-settings from ${orgName}` + const newCommit = await githubHub.rest.git.createCommit({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, message: commitMessage, tree: createdTree.data.sha, parents: [baseSha] }) + + // Update the branch ref to point to the new commit + await githubHub.rest.git.updateRef({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, ref: `heads/${branchName}`, sha: newCommit.data.sha }) + + robot.log.info(`Created commit ${newCommit.data.sha} on ${env.SAFE_SETTINGS_HUB_ORG}/${hubRepoName}@${branchName} with ${treeEntries.length} files`) + } catch (commitErr) { + robot.log.error(`Failed to create commit tree for ${orgName}: ${commitErr && commitErr.message ? commitErr.message : commitErr}`) + results.push({ org: orgName, error: `failed to commit files: ${commitErr && commitErr.message ? commitErr.message : String(commitErr)}` }) + continue + } + + // Create a PR in the hub repo for this branch + try { + const prTitle = `Import safe-settings from ${orgName}` + const prBody = `Automated import of settings from ${orgName} admin repo (${adminRepo}) into the hub.` + const created = await githubHub.rest.pulls.create({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: hubRepoName, title: prTitle, head: branchName, base: baseBranch, body: prBody }) + results.push({ org: orgName, pr: created.data && created.data.html_url }) + robot.log.info(`Created PR ${created.data && created.data.html_url} for ${orgName}`) + } catch (prErr) { + robot.log.error(`Failed to create PR for ${orgName}: ${prErr && prErr.message ? prErr.message : prErr}`) + results.push({ org: orgName, error: `failed to create PR: ${prErr && prErr.message ? prErr.message : String(prErr)}` }) + } + } catch (errInner) { + robot.log.error(`Error importing settings for org ${orgName}: ${errInner && errInner.message ? errInner.message : errInner}`) + results.push({ org: orgName, error: errInner && errInner.message ? errInner.message : String(errInner) }) + } + } + + return results + } catch (err) { + robot.log.error(`retrieveSettingsFromOrgs error: ${err && err.message ? err.message : err}`) + throw err + } +} + +// Properties used to identify matching items in arrays +const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefix', 'context'] + +/** + * Merge two JSON/YAML configuration strings at the object level. + * + * @param {string} json1 - First JSON/YAML content string (base) + * @param {string} json2 - Second JSON/YAML content string (overlay - takes precedence) + * @param {boolean} replaceArrays - If true (default), arrays in json2 replace arrays in json1. + * If false, arrays are merged intelligently: + * - Simple arrays: deduplicated (no duplicates added) + * - Object arrays: matched by name/username/etc and merged + * @returns {Object} Merged configuration object + * + * @example + * const json1 = ` + * teams: + * - team-a + * - team-b + * ` + * const json2 = ` + * teams: + * - team-b + * - team-c + * ` + * + * // Replace mode (default): result = { teams: ['team-b', 'team-c'] } + * const replaced = mergeConfigs(json1, json2) + * // or explicitly: mergeConfigs(json1, json2, true) + * + * // Smart merge mode: result = { teams: ['team-a', 'team-b', 'team-c'] } + * const smartMerge = mergeConfigs(json1, json2, false) + * mergeConfigs + * @example + * // Smart merge with objects + * const json1 = ` + * collaborators: + * - username: alice + * permission: push + * ` + * const json2 = ` + * collaborators: + * - username: alice + * permission: admin + * - username: bob + * permission: pull + * ` + * // Result: alice updated to admin, bob added + */ +function mergeConfigs (json1, json2, replaceArrays = true) { + // Parse input strings as YAML (which also handles JSON) + const obj1 = yaml.load(json1) || {} + const obj2 = yaml.load(json2) || {} + + // Perform the merge + return deepMerge(obj1, obj2, replaceArrays) +} + +/** + * Deep merge two objects with configurable array handling + * + * @param {*} target - Base object + * @param {*} source - Overlay object (takes precedence) + * @param {boolean} replaceArrays - Array merge strategy + * @returns {*} Merged result + */ +function deepMerge (target, source, replaceArrays) { + // Handle null/undefined + if (source === null || source === undefined) { + return target + } + if (target === null || target === undefined) { + return source + } + + // If source is not an object, it replaces target + if (typeof source !== 'object' || source === null) { + return source + } + + // Handle arrays + if (Array.isArray(source)) { + if (Array.isArray(target)) { + if (replaceArrays) { + // Replace: return only source array + return [...source] + } else { + // Smart merge: deduplicate primitives or merge objects by matching properties + return smartMergeArrays(target, source) + } + } + // Target is not an array, replace with source array + return [...source] + } + + // Handle objects (not arrays) + if (typeof target !== 'object' || target === null || Array.isArray(target)) { + // Target is not a plain object, replace with source + target = {} + } + + const result = { ...target } + + // Merge source properties into result + for (const key in source) { + // Skip prototype pollution + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue + } + + const sourceValue = source[key] + const targetValue = result[key] + + // Recursively merge if both are objects or arrays + if ( + sourceValue !== null && + typeof sourceValue === 'object' && + targetValue !== null && + typeof targetValue === 'object' + ) { + result[key] = deepMerge(targetValue, sourceValue, replaceArrays) + } else { + // For primitives, source overwrites target + result[key] = sourceValue + } + } + + return result +} + +/** + * Smart merge two arrays: deduplicates primitives, or matches objects by name/username/etc and deep-merges them. + * + * **Primitive arrays**: ['a', 'b'] + ['b', 'c'] → ['a', 'b', 'c'] + * + * **Object arrays** (via mergeBy): + * - Matches by NAME_FIELDS (name, username, actor_id, login, type, key_prefix, context) + * - Deep-merges matching objects (source properties override target, nested props preserved) + * - Appends non-matching source objects + * - Preserves target-only objects + * + * Example (labels): + * ``` + * target: [{ name: 'bug', color: 'red' }, { name: 'feature', color: 'blue' }] + * source: [{ name: 'bug', color: 'green' }, { name: 'docs', color: 'yellow' }] + * result: [{ name: 'bug', color: 'green' }, // updated + * { name: 'feature', color: 'blue' }, // preserved + * { name: 'docs', color: 'yellow' }] // added + * ``` + * + * @param {Array} target - Base array (primitives or objects) + * @param {Array} source - Overlay array (primitives or objects) + * @returns {Array} Merged array + */ +function smartMergeArrays (target, source) { + // Check if arrays contain objects or primitives + const hasObjects = source.some(item => item && typeof item === 'object' && !Array.isArray(item)) + + if (!hasObjects) { + // Primitives: deduplicate + const result = [...target] + source.forEach(item => { + if (!result.includes(item)) { + result.push(item) + } + }) + return result + } + + // Objects: match by NAME_FIELDS and deep-merge + // mergeBy(key, configvalidator, overridevalidator, properties, target, source, options, githubContext) + return mergeBy(null, null, null, NAME_FIELDS, target, source, undefined, undefined) +} + +// Export all internal functions for testability +module.exports = { + hubSyncHandler, + retrieveSettingsFromOrgs, + syncHubOrgUpdate, + syncHubGlobalsUpdate, + getOrgInstallation, + mergeConfigs +} diff --git a/lib/installationCache.js b/lib/installationCache.js new file mode 100644 index 000000000..5ec98619e --- /dev/null +++ b/lib/installationCache.js @@ -0,0 +1,149 @@ +// Installation cache with TTL for GitHub App installations. +// Provides a hybrid approach: live refresh when stale, fast reads otherwise. + +let cachedInstallations = [] +let cachedOrgLogins = [] +let lastFetchedAt = null +let inFlightPromise = null + +/** + * Returns the TTL (time-to-live) in milliseconds for the installation cache. + * Reads from INSTALLATION_CACHE_TTL_MS env variable, defaults to 60s, minimum 5s. + */ +const DEFAULT_TTL_MS = 60_000 +function getTtlMs () { + const v = parseInt(process.env.INSTALLATION_CACHE_TTL_MS, 10) + return isNaN(v) || v < 5_000 ? DEFAULT_TTL_MS : v +} + +/** + * Fetches all GitHub App installations using the provided robot instance. + * Returns an array of installation objects. Uses pagination for large orgs. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options (perPage) + * @returns {Promise} Array of installation objects + */ +async function fetchInstallations (robot, { perPage = 100 } = {}) { + const github = await robot.auth() + return github.paginate( + github.apps.listInstallations.endpoint.merge({ per_page: perPage }) + ) +} + +/** + * Refreshes the installation cache by fetching live installations from GitHub. + * Updates cachedInstallations, cachedOrgLogins, and lastFetchedAt. + * Ensures only one refresh is in flight at a time. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options for fetchInstallations + * @returns {Promise} Array of installation objects + */ +async function refresh (robot, opts = {}) { + if (inFlightPromise) return inFlightPromise + inFlightPromise = (async () => { + try { + const installs = await fetchInstallations(robot, opts) + cachedInstallations = installs + cachedOrgLogins = installs + .filter(i => i.account && i.account.type === 'Organization') + .map(i => i.account.login) + .sort() + lastFetchedAt = new Date() + } catch (e) { + robot.log && robot.log.warn && robot.log.warn(`Installation cache refresh failed: ${e.message}`) + throw e + } finally { + inFlightPromise = null + } + return cachedInstallations + })() + return inFlightPromise +} + +/** + * Starts a prefetch of installations to warm up the cache at startup. + * Returns a promise for the refresh operation. + * @param {Probot} robot - The Probot robot instance + * @param {Object} opts - Options for refresh + * @returns {Promise} Array of installation objects + */ +function startPrefetch (robot, opts = {}) { + return refresh(robot, opts) +} + +/** + * Initialize cache (always prefetch once at startup) and log result. + */ + +/** + * Initializes the installation cache by prefetching installations at startup. + * Logs the result and returns true/false for success/failure. + * @param {Probot} robot - The Probot robot instance + * @returns {Promise} True if prefetch succeeded, false otherwise + */ +function initCache (robot) { + return startPrefetch(robot) + .then(installs => { + robot.log && robot.log.info && robot.log.info(`Installation cache prefetched ${installs.length} installs (${cachedOrgLogins.length} orgs) [TTL=${getTtlMs()}ms]`) + return true + }) + .catch(e => { + robot.log && robot.log.warn && robot.log.warn(`Installation cache prefetch failed: ${e.message}`) + return false + }) +} + +/** + * Ensures the cache is fresh by checking TTL and refreshing if stale. + * Called before serving cached installations to guarantee freshness. + * @param {Probot} robot - The Probot robot instance + */ +async function ensureFresh (robot) { + const ttl = getTtlMs() + if (!lastFetchedAt || (Date.now() - lastFetchedAt.getTime()) > ttl) { + try { await refresh(robot) } catch (_) { /* stale ok */ } + } +} + +/** + * Returns the cached installations, refreshing if the cache is stale. + * Always returns a copy of the cached array. + * @param {Probot} robot - The Probot robot instance + * @returns {Promise} Array of installation objects + */ +async function getInstallations (robot) { + await ensureFresh(robot) + return cachedInstallations.slice() +} + +/** + * Returns a copy of the cached organization logins (GitHub org names). + * @returns {Array} Array of org login strings + */ +function getOrgLogins () { return cachedOrgLogins.slice() } + +/** + * Returns the Date when installations were last fetched. + * @returns {Date|null} Last fetched date or null if never fetched + */ +function getLastFetchedAt () { return lastFetchedAt } + +/** + * Test-only helper: Forces the cache to appear stale on next access. + * Used for diagnostics and testing cache refresh logic. + */ +function __forceStale () { + lastFetchedAt = new Date(Date.now() - (getTtlMs() + 10_000)) +} + +module.exports = { + startPrefetch, + initCache, + refresh, + getInstallations, + getOrgLogins, + getLastFetchedAt, + // for tests / diagnostics + _debug: () => ({ size: cachedInstallations.length, lastFetchedAt }), + __forceStale +} diff --git a/lib/mergeDeep.js b/lib/mergeDeep.js index ab278e5c2..24feecb33 100644 --- a/lib/mergeDeep.js +++ b/lib/mergeDeep.js @@ -1,3 +1,23 @@ +/** + * MergeDeep - Deep comparison and merging utility for GitHub settings configuration + * + * This module provides functionality to compare desired configuration (source) with + * actual GitHub API state (target) and determine what changes need to be made: + * additions, modifications, and deletions. + * + * Key features: + * - Deep recursive comparison of objects and arrays + * - Smart array element matching using identifying fields (name, username, etc.) + * - Handles GitHub API quirks (e.g., null actor_id for OrganizationAdmin) + * - Ignores API-managed fields (URLs, metadata) to reduce noise + * - One-way comparison: only validates source properties exist in target + * - Special handling for nested configuration (e.g., rule parameters) + * + * The comparison logic is asymmetric: properties in target that don't exist in + * source are NOT treated as deletions, since the GitHub API often returns + * additional metadata fields that aren't part of the user configuration. + */ + const mergeBy = require('./mergeArrayBy') const DeploymentConfig = require('./deploymentConfig') @@ -5,6 +25,44 @@ const NAME_FIELDS = ['name', 'username', 'actor_id', 'login', 'type', 'key_prefi const NAME_USERNAME_PROPERTY = item => NAME_FIELDS.find(prop => Object.prototype.hasOwnProperty.call(item, prop)) const GET_NAME_USERNAME_PROPERTY = item => { if (NAME_USERNAME_PROPERTY(item)) return item[NAME_USERNAME_PROPERTY(item)] } +// Fields within a rule's `parameters` that are managed/defaulted by the GitHub API. +// They should not be treated as user-driven deletions when omitted from config. +const PARAM_DELETION_IGNORE = ['allowed_merge_methods'] + +// Order-insensitive JSON serialization used as a fallback identity for array +// elements that have no named identifying field (e.g. `code_scanning_tools`). +// The GitHub API often returns object keys in a different order than config, so +// a plain JSON.stringify would treat semantically-equal items as different and +// produce spurious add/delete churn. Sorting keys recursively avoids that. +const stableStringify = value => { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +// Compute the identity value for an array element so the same logical item in +// `source` (config) and `target` (GitHub API) can be paired during comparison. +// Returns the raw identifying value (so a bare string shorthand like 'developers' +// still matches an object like { name: 'developers' }). Special-cases bypass +// actors: GitHub returns `actor_id: null` for role-based actor types such as +// `OrganizationAdmin`, so we key those on `actor_type` to avoid spurious +// add/delete churn when config supplies an explicit id. +const getItemIdentity = item => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined + if (Object.prototype.hasOwnProperty.call(item, 'actor_type') && + Object.prototype.hasOwnProperty.call(item, 'bypass_mode')) { + if (item.actor_id === null || item.actor_id === undefined || item.actor_type === 'OrganizationAdmin') { + return item.actor_type + } + return item.actor_id + } + return GET_NAME_USERNAME_PROPERTY(item) +} + class MergeDeep { constructor (log, github, ignorableFields = [], configvalidators = {}, overridevalidators = {}) { this.log = log @@ -53,7 +111,7 @@ class MergeDeep { * @param {*} deletions aggregated so far * @returns object with additions, modifications, and deletions */ - compareDeep (t, s, additions, modifications, deletions) { + compareDeep (t, s, additions, modifications, deletions, parentKey) { // Preemtively return if the source is not an object or array if (!this.isObject(s)) { return { additions, modifications, deletions, hasChanges: s !== t } @@ -92,8 +150,8 @@ class MergeDeep { // So any property in the target that is not in the source is not treated as a deletion for (const key in source) { // Skip prototype pollution vectors - if (key === "__proto__" || key === "constructor") { - continue; + if (key === '__proto__' || key === 'constructor') { + continue } // Logic specific for Github // API response includes urls for resources, or other ignorable fields; we can ignore them @@ -126,18 +184,25 @@ class MergeDeep { this.processArrays(key, sourceValue, targetValue, deletions, additions, modifications) } else { // recursively compare the objects until we reach a primitive - this.compareDeep(targetValue, sourceValue, additions[key], modifications[key], deletions[key]) + this.compareDeep(targetValue, sourceValue, additions[key], modifications[key], deletions[key], key) this.validateOverride(key, targetValue, sourceValue) } } else { // The entry is a simple primitive if (targetValue !== sourceValue) { - // Note: source[key] cannot be undefined here since we are iterating on source keys - // so we don't need to check for that. - // The entries are different. It is an addition - modifications[key] = sourceValue - // retroactively add `name` or `username` to the modifications - // Since those are the only fields that can be used to identify the resource - this.addIdentifyingAttribute(source, key, modifications) + // GitHub returns `actor_id: null` for role-based bypass actor types + // (e.g. OrganizationAdmin) regardless of the id supplied in config. + // Don't treat that placeholder mismatch as a modification. + if (key === 'actor_id' && (targetValue === null || sourceValue === null)) { + // treat as equal + } else { + // Note: source[key] cannot be undefined here since we are iterating on source keys + // so we don't need to check for that. + // The entries are different. It is an addition + modifications[key] = sourceValue + // retroactively add `name` or `username` to the modifications + // Since those are the only fields that can be used to identify the resource + this.addIdentifyingAttribute(source, key, modifications) + } } else { // The entry is the same in both objects } @@ -147,6 +212,22 @@ class MergeDeep { additions = this.removeEmptyAndNulls(additions, key) deletions = this.removeEmptyAndNulls(deletions, key) } + + // Detect deletions for config-meaningful nested objects (e.g. a rule's + // `parameters`). The GitHub API is additive for top-level metadata, so we + // only do this for known config subtrees to avoid flagging server-managed + // or metadata fields (timestamps, _links, source_type, etc.) as deletions. + if (parentKey === 'parameters' && this.isObjectNotArray(target) && this.isObjectNotArray(source)) { + for (const key in target) { + if (key === '__proto__' || key === 'constructor') continue + if (key.indexOf('url') >= 0 || this.ignorableFields.indexOf(key) >= 0) continue + if (PARAM_DELETION_IGNORE.indexOf(key) >= 0) continue + if (!(key in source)) { + // Present in GitHub but removed from config => a deletion + deletions[key] = target[key] + } + } + } // Unwind the topleve array from the object if (firstInvocation) { if (additions.__array) { @@ -184,7 +265,8 @@ class MergeDeep { if (source.length < target.length) { const dels = target.filter(item => { if (this.isObjectNotArray(item)) { - return !source.some(sourceItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(sourceItem)) + const itemId = getItemIdentity(item) || stableStringify(item) + return !source.some(sourceItem => (getItemIdentity(sourceItem) || stableStringify(sourceItem)) === itemId) } else { return !source.includes(item) } @@ -200,9 +282,10 @@ class MergeDeep { continue } else { // Not visited yet - const id = GET_NAME_USERNAME_PROPERTY(a) - if (id) { - visited[id] = a + // Use identifying property (name, username, actor_type, etc.) or fall back to JSON representation for objects without named properties + const visitedId = getItemIdentity(a) || stableStringify(a) + if (!visited[visitedId]) { + visited[visitedId] = a } } } else { @@ -222,7 +305,11 @@ class MergeDeep { // Elements that are not in target are additions additions[key] = combined.filter(item => { if (this.isObjectNotArray(item)) { - return !target.some(targetItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(targetItem)) + const itemId = getItemIdentity(item) || stableStringify(item) + return !target.some(targetItem => { + const targetId = getItemIdentity(targetItem) || stableStringify(targetItem) + return itemId === targetId + }) } else { return !target.includes(item) } @@ -233,7 +320,11 @@ class MergeDeep { // Elements that not in source are deletions deletions[key] = combined.filter(item => { if (this.isObjectNotArray(item)) { - return !source.some(sourceItem => GET_NAME_USERNAME_PROPERTY(item) === GET_NAME_USERNAME_PROPERTY(sourceItem)) + const itemId = getItemIdentity(item) || stableStringify(item) + return !source.some(sourceItem => { + const sourceId = getItemIdentity(sourceItem) || stableStringify(sourceItem) + return itemId === sourceId + }) } else { return !source.includes(item) } @@ -242,14 +333,15 @@ class MergeDeep { } compareDeepIfVisited (additions, modifications, deletions, a, visited) { - const id = GET_NAME_USERNAME_PROPERTY(a) - if (visited[id]) { + // Use identifying property or fall back to JSON representation for objects without named properties + const visitedId = getItemIdentity(a) || stableStringify(a) + if (visited[visitedId]) { // Common array in target and source modifications.push({}) additions.push({}) deletions.push({}) - if (visited[id]) { - this.compareDeep(a, visited[id], additions[additions.length - 1], modifications[modifications.length - 1], deletions[deletions.length - 1]) + if (visited[visitedId]) { + this.compareDeep(a, visited[visitedId], additions[additions.length - 1], modifications[modifications.length - 1], deletions[deletions.length - 1]) } // Any addtions for the matching key must be moved to modifications const lastAddition = additions[additions.length - 1] @@ -270,12 +362,15 @@ class MergeDeep { } // Add name attribute to the modifications to make it look better ; it won't be added otherwise as it would be the same if (!this.isEmpty(modifications[modifications.length - 1])) { - if (visited[id]) { - modifications[modifications.length - 1][NAME_USERNAME_PROPERTY(a)] = id + if (visited[visitedId]) { + const displayProp = NAME_USERNAME_PROPERTY(a) + if (displayProp) { + modifications[modifications.length - 1][displayProp] = a[displayProp] + } } } - if (visited[id]) { - delete visited[id] + if (visited[visitedId]) { + delete visited[visitedId] } return true } diff --git a/lib/plugins/branches.js b/lib/plugins/branches.js index d28e2f905..80a32fb8c 100644 --- a/lib/plugins/branches.js +++ b/lib/plugins/branches.js @@ -5,10 +5,10 @@ const Overrides = require('./overrides') const ignorableFields = [] const previewHeaders = { accept: 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' } const overrides = { - 'contexts': { - 'action': 'reset', - 'type': 'array' - }, + contexts: { + action: 'reset', + type: 'array' + } } module.exports = class Branches extends ErrorStash { diff --git a/lib/plugins/custom_properties.js b/lib/plugins/custom_properties.js index 6b1f3ab36..35f0144da 100644 --- a/lib/plugins/custom_properties.js +++ b/lib/plugins/custom_properties.js @@ -12,10 +12,24 @@ module.exports = class CustomProperties extends Diffable { // Force all names to lowercase to avoid comparison issues. normalizeEntries () { - this.entries = this.entries.map(({ name, value }) => ({ - name: name.toLowerCase(), - value - })) + this.entries = this.entries.reduce((normalizedEntries, entry) => { + if (!entry || typeof entry !== 'object') { + return normalizedEntries + } + + const entryName = entry.name || entry.property_name + + if (typeof entryName !== 'string') { + return normalizedEntries + } + + normalizedEntries.push({ + name: entryName.toLowerCase(), + value: entry.value + }) + + return normalizedEntries + }, []) } async find () { @@ -25,7 +39,7 @@ module.exports = class CustomProperties extends Diffable { this.log.debug(`Getting all custom properties for the repo ${repoFullName}`) const customProperties = await this.github.paginate( - this.github.repos.getCustomPropertiesValues, + this.github.rest.repos.getCustomPropertiesValues, { owner, repo, @@ -38,10 +52,24 @@ module.exports = class CustomProperties extends Diffable { // Force all names to lowercase to avoid comparison issues. normalize (properties) { - return properties.map(({ property_name: propertyName, value }) => ({ - name: propertyName.toLowerCase(), - value - })) + return properties.reduce((normalizedProperties, property) => { + if (!property || typeof property !== 'object') { + return normalizedProperties + } + + const propertyName = property.property_name || property.name + + if (typeof propertyName !== 'string') { + return normalizedProperties + } + + normalizedProperties.push({ + name: propertyName.toLowerCase(), + value: property.value + }) + + return normalizedProperties + }, []) } comparator (existing, attrs) { @@ -82,14 +110,14 @@ module.exports = class CustomProperties extends Diffable { return new NopCommand( this.constructor.name, this.repo, - this.github.repos.createOrUpdateCustomPropertiesValues.endpoint(params), + this.github.rest.repos.createOrUpdateCustomPropertiesValues.endpoint(params), `${operation} Custom Property` ) } try { this.log.debug(`${operation} Custom Property "${name}" for the repo ${repoFullName}`) - await this.github.repos.createOrUpdateCustomPropertiesValues(params) + await this.github.rest.repos.createOrUpdateCustomPropertiesValues(params) this.log.debug(`Successfully ${operation.toLowerCase()}d Custom Property "${name}" for the repo ${repoFullName}`) } catch (e) { this.logError(`Error during ${operation} Custom Property "${name}" for the repo ${repoFullName}: ${e.message || e}`) diff --git a/lib/plugins/custom_repository_roles.js b/lib/plugins/custom_repository_roles.js new file mode 100644 index 000000000..1931b47cc --- /dev/null +++ b/lib/plugins/custom_repository_roles.js @@ -0,0 +1,119 @@ +const Diffable = require('./diffable') +const NopCommand = require('../nopcommand') +const MergeDeep = require('../mergeDeep') + +// Fields returned by the API that we should ignore when diffing +const ignorableFields = ['id', 'organization', 'created_at', 'updated_at'] + +const version = { + 'X-GitHub-Api-Version': '2026-03-10' +} + +module.exports = class CustomRepositoryRoles extends Diffable { + constructor (nop, github, repo, entries, log, errors) { + super(nop, github, repo, entries, log, errors) + this.github = github + this.repo = repo + this.entries = entries + this.log = log + this.nop = nop + } + + // Find all Custom Repository Roles for the org + find () { + this.log.debug(`Getting all custom repository roles for the org ${this.repo.owner}`) + + return this.github.request('GET /orgs/{org}/custom-repository-roles', { + org: this.repo.owner, + headers: version + }).then(res => { + const roles = (res && res.data && res.data.custom_roles) || [] + // Strip noise so deep-diff focuses on the configurable fields + return roles.map(r => ({ + id: r.id, + name: r.name, + description: r.description, + base_role: r.base_role, + permissions: r.permissions + })) + }).catch(e => { + return this.handleError(e, []) + }) + } + + comparator (existing, attrs) { + return existing.name === attrs.name + } + + changed (existing, attrs) { + const mergeDeep = new MergeDeep(this.log, this.github, ignorableFields) + const merged = mergeDeep.compareDeep(existing, attrs) + return merged.hasChanges + } + + update (existing, attrs) { + const parms = this.wrapAttrs(Object.assign({ role_id: existing.id }, attrs)) + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('PATCH /orgs/{org}/custom-repository-roles/{role_id}', parms), 'Update Custom Repository Role') + ]) + } + this.log.debug(`Updating Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`) + return this.github.request('PATCH /orgs/{org}/custom-repository-roles/{role_id}', parms).then(res => { + this.log.debug(`Custom Repository Role updated successfully ${JSON.stringify(res.url)}`) + return res + }).catch(e => { + return this.handleError(e) + }) + } + + add (attrs) { + const parms = this.wrapAttrs(attrs) + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('POST /orgs/{org}/custom-repository-roles', parms), 'Create Custom Repository Role') + ]) + } + this.log.debug(`Creating Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`) + return this.github.request('POST /orgs/{org}/custom-repository-roles', parms).then(res => { + this.log.debug(`Custom Repository Role created successfully ${JSON.stringify(res.url)}`) + return res + }).catch(e => { + return this.handleError(e) + }) + } + + remove (existing) { + const parms = this.wrapAttrs({ role_id: existing.id }) + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, this.github.request.endpoint('DELETE /orgs/{org}/custom-repository-roles/{role_id}', parms), 'Delete Custom Repository Role') + ]) + } + this.log.debug(`Deleting Custom Repository Role with the following values ${JSON.stringify(parms, null, 2)}`) + return this.github.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', parms).then(res => { + this.log.debug(`Custom Repository Role deleted successfully ${JSON.stringify(res.url)}`) + return res + }).catch(e => { + if (e.status === 404) { + return + } + return this.handleError(e) + }) + } + + wrapAttrs (attrs) { + return Object.assign({}, attrs, { + org: this.repo.owner, + headers: version + }) + } + + handleError (e, returnValue) { + this.logError(e) + if (this.nop) { + return Promise.resolve([(new NopCommand(this.constructor.name, this.repo, null, `error: ${e}`, 'ERROR'))]) + } + return Promise.resolve(returnValue) + } +} diff --git a/lib/plugins/diffable.js b/lib/plugins/diffable.js index 069c68c78..57d4cae17 100644 --- a/lib/plugins/diffable.js +++ b/lib/plugins/diffable.js @@ -32,6 +32,9 @@ module.exports = class Diffable extends ErrorStash { this.entries = entries this.log = log this.nop = nop + // When true, remove() calls are suppressed (additive_plugins feature). + // Callers (updateRepos) set this after construction; defaults to false. + this.additive = false } filterEntries () { @@ -62,6 +65,11 @@ module.exports = class Diffable extends ErrorStash { sync () { const resArray = [] + // Will be set to true when this plugin makes (or would make, in nop mode) + // any add/update/remove. Consumers (e.g. Settings suborg re-evaluation) + // can read `plugin.hasChanges` after `sync()` resolves to know whether + // anything actually changed for this repo. + this.hasChanges = false if (this.entries) { let filteredEntries = this.filterEntries() // this.log.debug(`filtered entries are ${JSON.stringify(filteredEntries)}`) @@ -72,6 +80,7 @@ module.exports = class Diffable extends ErrorStash { const compare = mergeDeep.compareDeep(existingRecords, filteredEntries) const results = { msg: 'Changes found', additions: compare.additions, modifications: compare.modifications, deletions: compare.deletions } this.log.debug(`Results of comparing ${this.constructor.name} diffable target ${JSON.stringify(existingRecords)} with source ${JSON.stringify(filteredEntries)} is ${JSON.stringify(results)}`) + this.hasChanges = !!compare.hasChanges if (!compare.hasChanges) { this.log.debug(`There are no changes for ${this.constructor.name} for repo ${this.repo.repo}. Skipping changes`) return Promise.resolve() @@ -94,17 +103,31 @@ module.exports = class Diffable extends ErrorStash { const changes = [] - existingRecords.forEach(x => { - if (!filteredEntries.find(y => this.comparator(x, y))) { - const change = this.remove(x).then(res => { - if (this.nop) { - return resArray.push(res) - } - return res - }) - changes.push(change) + if (this.additive) { + // Additive mode: skip all remove() calls. In NOP mode, emit an INFO + // message so PR reviewers can see what deletions are being suppressed. + if (this.nop && compare.deletions && compare.deletions.length > 0) { + resArray.push(new NopCommand( + this.constructor.name, + this.repo, + null, + `Additive mode active: ${compare.deletions.length} deletion(s) suppressed by additive_plugins`, + 'INFO' + )) } - }) + } else { + existingRecords.forEach(x => { + if (!filteredEntries.find(y => this.comparator(x, y))) { + const change = this.remove(x).then(res => { + if (this.nop) { + return resArray.push(res) + } + return res + }) + changes.push(change) + } + }) + } filteredEntries.forEach(attrs => { const existing = existingRecords.find(record => { diff --git a/lib/plugins/environments.js b/lib/plugins/environments.js index 73bef0e0f..5f8044bd3 100644 --- a/lib/plugins/environments.js +++ b/lib/plugins/environments.js @@ -23,7 +23,7 @@ module.exports = class Environments extends Diffable { policies.push({ name: policy, type: 'branch' }) } else if (typeof policy === 'object' && Array.isArray(policy.names)) { policy.names.forEach(name => { - policies.push({ name: name, type: policy.type }) + policies.push({ name, type: policy.type }) }) } }) diff --git a/lib/plugins/overrides.js b/lib/plugins/overrides.js index 0030b1246..b6d68942d 100644 --- a/lib/plugins/overrides.js +++ b/lib/plugins/overrides.js @@ -74,23 +74,23 @@ module.exports = class Overrides extends ErrorStash { // - The POST method for rulesets (create) allows for one override only. static removeOverrides (overrides, source, existing) { Object.entries(overrides).forEach(([override, props]) => { - let sourceRefs = Overrides.getObjectRef(source, override) - let data = JSON.stringify(sourceRefs) + const sourceRefs = Overrides.getObjectRef(source, override) + const data = JSON.stringify(sourceRefs) if (data.includes('{{EXTERNALLY_DEFINED}}')) { - let existingRefs = Overrides.getObjectRef(existing, override) + const existingRefs = Overrides.getObjectRef(existing, override) sourceRefs.forEach(sourceRef => { if (existingRefs[0]) { sourceRef[override] = existingRefs[0][override] - } else if (props['action'] === 'delete') { - Overrides.removeTopLevelParent(source, sourceRef[override], props['parents']) + } else if (props.action === 'delete') { + Overrides.removeTopLevelParent(source, sourceRef[override], props.parents) delete sourceRef[override] - } else if (props['type'] === 'array') { + } else if (props.type === 'array') { sourceRef[override] = [] - } else if (props['type'] === 'dict') { + } else if (props.type === 'dict') { sourceRef[override] = {} } else { - throw new Error(`Unknown type ${props['type']} for ${override}`) + throw new Error(`Unknown type ${props.type} for ${override}`) } }) } diff --git a/lib/plugins/repository.js b/lib/plugins/repository.js index 14599f608..3333225d2 100644 --- a/lib/plugins/repository.js +++ b/lib/plugins/repository.js @@ -62,6 +62,10 @@ module.exports = class Repository extends ErrorStash { const resArray = [] this.log.debug(`Syncing Repo ${this.settings.name}`) this.settings.name = this.settings.name || this.settings.repo + // Change signals consumed by Settings suborg re-evaluation. + this.hasChanges = false + this.renamed = false + this.created = false // let hasChanges = false // let hasTopicChanges = false return this.github.repos.get(this.repo) @@ -74,6 +78,12 @@ module.exports = class Repository extends ErrorStash { const topicChanges = mergeDeep.compareDeep({ entries: resp.data.topics }, { entries: this.topics }) // hasTopicChanges = topicChanges.additions.length > 0 || topicChanges.modifications.length > 0 + this.hasChanges = !!(changes.hasChanges || topicChanges.hasChanges) + // A repo rename (changing the slug) shows up as a `name` modification. + if (changes.hasChanges && this.settings.name && resp.data.name && this.settings.name !== resp.data.name) { + this.renamed = true + } + // const results = JSON.stringify(changes, null, 2) const results = { msg: `${this.constructor.name} settings changes`, additions: changes.additions, modifications: changes.modifications, deletions: changes.deletions } @@ -120,6 +130,8 @@ module.exports = class Repository extends ErrorStash { }).catch(e => { if (e.status === 404) { if (this.force_create) { + this.hasChanges = true + this.created = true if (this.template) { this.log.debug(`Creating repo using template ${this.template}`) const options = { template_owner: this.repo.owner, template_repo: this.template, owner: this.repo.owner, name: this.repo.repo, private: (this.settings.private ? this.settings.private : true), description: this.settings.description ? this.settings.description : '' } diff --git a/lib/plugins/rulesets.js b/lib/plugins/rulesets.js index b77ead1bd..e02383649 100644 --- a/lib/plugins/rulesets.js +++ b/lib/plugins/rulesets.js @@ -4,16 +4,29 @@ const MergeDeep = require('../mergeDeep') const Overrides = require('./overrides') const ignorableFields = [] const overrides = { - 'required_status_checks': { - 'action': 'delete', - 'parents': 3, - 'type': 'dict' - }, + required_status_checks: { + action: 'delete', + parents: 3, + type: 'dict' + } } const version = { 'X-GitHub-Api-Version': '2022-11-28' } + +// GitHub's built-in (base) repository role IDs. These are not returned by the +// custom-repository-roles API, so they are mapped statically here to allow +// users to reference them by name in a ruleset's bypass_actors. Custom roles +// are resolved dynamically via GET /orgs/{org}/custom-repository-roles. +const BASE_REPOSITORY_ROLE_IDS = { + read: 1, + triage: 2, + write: 3, + maintain: 4, + admin: 5 +} + module.exports = class Rulesets extends Diffable { constructor (nop, github, repo, entries, log, errors, scope) { super(nop, github, repo, entries, log, errors) @@ -23,6 +36,137 @@ module.exports = class Rulesets extends Diffable { this.log = log this.nop = nop this.scope = scope || 'repo' + // Cache for name -> id lookups, scoped to a single sync() invocation. + this.idCache = new Map() + } + + // Resolve human-friendly names to the numeric ids GitHub expects before the + // normal Diffable sync runs. This lets users define rulesets using a team + // slug, username, GitHub App slug, or repository role name instead of having + // to look up the corresponding id. Names are resolved in place and the helper + // attribute is removed so the payload matches what GitHub returns (which only + // contains ids), keeping compareDeep stable and backward compatible with + // policies that already use ids. + async sync () { + try { + await this.resolveNamesToIds() + } catch (e) { + return this.handleError(e) + } + return super.sync() + } + + async resolveNamesToIds () { + if (!this.entries) return + this.idCache = new Map() + for (const ruleset of this.entries) { + if (Array.isArray(ruleset.bypass_actors)) { + for (const actor of ruleset.bypass_actors) { + await this.resolveBypassActor(actor) + } + } + const rules = Array.isArray(ruleset.rules) ? ruleset.rules : [] + for (const rule of rules) { + const reviewers = rule && rule.parameters && rule.parameters.required_reviewers + if (Array.isArray(reviewers)) { + for (const entry of reviewers) { + await this.resolveReviewer(entry) + } + } + } + } + } + + async resolveBypassActor (actor) { + if (!actor || actor.name === undefined || actor.name === null) return + if (actor.actor_id !== undefined && actor.actor_id !== null) { + throw new Error(`Ruleset bypass_actor cannot specify both 'name' ('${actor.name}') and 'actor_id' (${actor.actor_id}). Use one or the other.`) + } + actor.actor_id = await this.resolveActorId(actor.actor_type, actor.name) + delete actor.name + } + + async resolveReviewer (entry) { + const reviewer = entry && entry.reviewer + if (!reviewer || reviewer.slug === undefined || reviewer.slug === null) return + if (reviewer.id !== undefined && reviewer.id !== null) { + throw new Error(`Ruleset required_reviewer cannot specify both 'slug' ('${reviewer.slug}') and 'id' (${reviewer.id}). Use one or the other.`) + } + reviewer.id = await this.resolveTeamId(reviewer.slug) + delete reviewer.slug + } + + async resolveActorId (actorType, name) { + switch (actorType) { + case 'Team': + return this.resolveTeamId(name) + case 'User': + return this.resolveUserId(name) + case 'Integration': + return this.resolveIntegrationId(name) + case 'RepositoryRole': + return this.resolveRepositoryRoleId(name) + default: + throw new Error(`Cannot resolve 'name' '${name}' for actor_type '${actorType}'. Name resolution is only supported for Team, User, Integration, and RepositoryRole. Use 'actor_id' instead.`) + } + } + + async cachedLookup (key, fn) { + if (this.idCache.has(key)) return this.idCache.get(key) + const value = await fn() + this.idCache.set(key, value) + return value + } + + async resolveTeamId (slug) { + return this.cachedLookup(`Team:${slug}`, async () => { + try { + const res = await this.github.teams.getByName({ org: this.repo.owner, team_slug: slug }) + return res.data.id + } catch (e) { + throw new Error(`Unable to resolve Team slug '${slug}' to an id in org '${this.repo.owner}': ${e.status || e.message}`) + } + }) + } + + async resolveUserId (username) { + return this.cachedLookup(`User:${username}`, async () => { + try { + const res = await this.github.request('GET /users/{username}', { username }) + return res.data.id + } catch (e) { + throw new Error(`Unable to resolve User '${username}' to an id: ${e.status || e.message}`) + } + }) + } + + async resolveIntegrationId (slug) { + return this.cachedLookup(`Integration:${slug}`, async () => { + try { + const res = await this.github.request('GET /apps/{app_slug}', { app_slug: slug }) + return res.data.id + } catch (e) { + throw new Error(`Unable to resolve Integration (GitHub App) slug '${slug}' to an id: ${e.status || e.message}`) + } + }) + } + + async resolveRepositoryRoleId (name) { + return this.cachedLookup(`RepositoryRole:${name}`, async () => { + const baseId = BASE_REPOSITORY_ROLE_IDS[String(name).toLowerCase()] + if (baseId !== undefined) return baseId + try { + const res = await this.github.request('GET /orgs/{org}/custom-repository-roles', { org: this.repo.owner }) + const roles = (res.data && res.data.custom_roles) || [] + const match = roles.find(role => role.name === name) + if (!match) { + throw new Error(`no custom repository role named '${name}' found in org '${this.repo.owner}'`) + } + return match.id + } catch (e) { + throw new Error(`Unable to resolve RepositoryRole '${name}' to an id: ${e.status || e.message}`) + } + }) } // Find all Rulesets for this org @@ -81,6 +225,7 @@ module.exports = class Rulesets extends Diffable { return res ? res.flat(1) : [] }) }).catch(e => { + if (this.nop && e.status === 404) return [] return this.handleError(e, []) }) } diff --git a/lib/plugins/teams.js b/lib/plugins/teams.js index 4d7f79273..0d655363a 100644 --- a/lib/plugins/teams.js +++ b/lib/plugins/teams.js @@ -2,7 +2,30 @@ const Diffable = require('./diffable') const NopCommand = require('../nopcommand') const teamRepoEndpoint = '/orgs/:owner/teams/:team_slug/repos/:owner/:repo' +const listExternalGroupsEndpoint = 'GET /orgs/{org}/external-groups' +const teamExternalGroupsEndpoint = '/orgs/{org}/teams/{team_slug}/external-groups' + module.exports = class Teams extends Diffable { + // Override Diffable.sync to also reconcile the optional `external_group` + // link on each team entry after the normal team-repo permission sync. + // This runs regardless of whether the team-repo association was added, + // updated, or already in sync -- so updating only `external_group` on a + // team that already has correct repo permissions still triggers the link. + async sync () { + const res = await super.sync() + if (!this.entries) return res + + const filtered = this.filterEntries() + const entriesWithExternalGroup = filtered.filter(e => e && e.external_group) + if (entriesWithExternalGroup.length === 0) return res + + const nopCommands = Array.isArray(res) ? res : [] + for (const attrs of entriesWithExternalGroup) { + await this.syncExternalGroup(attrs, this.nop ? nopCommands : undefined) + } + return this.nop ? nopCommands : res + } + async find () { this.log.debug(`Finding teams for ${this.repo.owner}/${this.repo.repo}`) return this.github.paginate(this.github.repos.listTeams, this.repo).then(res => { @@ -138,4 +161,118 @@ module.exports = class Teams extends Diffable { permission: attrs.permission } } + + // Resolve the org's external-group display name -> group_id. Lazily builds + // a per-org Map (name -> id) the first time it's needed within a sync, and + // caches it on the shared `github` client so multiple repos / teams in the + // same sync only paginate `GET /orgs/{org}/external-groups` once per org. + // Returns null when the named group does not exist for the org (logs an + // error so the user can correct their yaml). + async resolveExternalGroupId (groupName) { + if (!this.github.__externalGroupsCache) { + this.github.__externalGroupsCache = new Map() + } + const cache = this.github.__externalGroupsCache + const org = this.repo.owner + if (!cache.has(org)) { + try { + // The external-groups endpoint returns { total_count, groups: [...] } + // and is not in Octokit's known-pagination list, so we must pass a + // map function that extracts the `groups` array from each page; + // otherwise paginate() yields the raw response objects and we'd + // silently fail to find any names. + const groups = await this.github.paginate( + listExternalGroupsEndpoint, + { org, per_page: 100 }, + (response) => (response && response.data && response.data.groups) || [] + ) + const byName = new Map() + for (const g of groups) { + if (g && g.group_name) byName.set(g.group_name, g.group_id) + } + this.log.debug(`Loaded ${byName.size} external group(s) for org ${org}: ${JSON.stringify(Array.from(byName.keys()))}`) + cache.set(org, byName) + } catch (e) { + this.logError(`Error listing external groups for org ${org}: ${e}`) + // Cache an empty map so we don't retry-storm the API within this sync. + cache.set(org, new Map()) + } + } + const id = cache.get(org).get(groupName) + if (id === undefined) { + return null + } + return id + } + + // Link a team to an external IdP group identified by display name. Only + // acts when the team entry carries an `external_group` property. Idempotent: + // checks the current link first and skips the PATCH if already linked to + // the same group_id. Sets `this.hasChanges = true` only when a PATCH + // actually fires, so the suborg re-evaluation logic in lib/settings.js sees + // a real change signal. + async syncExternalGroup (attrs, nopCommands) { + const groupName = attrs && attrs.external_group + if (!groupName) return + + const groupId = await this.resolveExternalGroupId(groupName) + if (groupId === null) { + const msg = `External group '${groupName}' not found for org ${this.repo.owner} (team '${attrs.name}').` + // logError: feeds the synchronous-run end-of-run errors summary. + this.logError(msg) + // For PR dry-run / nop mode, also surface the failure in the check_run + // output -- which is built from results entries with type === 'ERROR'. + if (this.nop && Array.isArray(nopCommands)) { + nopCommands.push(new NopCommand(this.constructor.name, this.repo, null, msg, 'ERROR')) + } + return + } + + const linkParams = { + org: this.repo.owner, + team_slug: attrs.name, + group_id: groupId + } + + if (this.nop) { + if (Array.isArray(nopCommands)) { + nopCommands.push(new NopCommand( + this.constructor.name, + this.repo, + this.github.request.endpoint(`PATCH ${teamExternalGroupsEndpoint}`, linkParams), + `Link team ${attrs.name} to external group '${groupName}'` + )) + } + return + } + + // Idempotency: skip the PATCH if the team is already linked to this group. + try { + const current = await this.github.request(`GET ${teamExternalGroupsEndpoint}`, { + org: this.repo.owner, + team_slug: attrs.name + }) + const currentGroups = (current && current.data && current.data.groups) || [] + if (currentGroups.some(g => g.group_id === groupId)) { + this.log.debug(`Team ${attrs.name} is already linked to external group '${groupName}' (id=${groupId}); skipping.`) + return + } + } catch (e) { + // 404 here means no current link; fall through to PATCH. Any other + // error is non-fatal -- the PATCH itself is idempotent on the server. + if (e.status !== 404) { + this.logError(`Error fetching current external group for team ${attrs.name}: ${e}`) + } + } + + try { + await this.github.request(`PATCH ${teamExternalGroupsEndpoint}`, linkParams) + this.log.debug(`Linked team ${attrs.name} to external group '${groupName}' (id=${groupId}).`) + // Surface this change so suborg re-evaluation (in lib/settings.js) and + // other consumers see that the team plugin made a real change. + this.hasChanges = true + } catch (e) { + this.logError(`Error linking team ${attrs.name} to external group '${groupName}' (id=${groupId}): ${e}`) + } + } } diff --git a/lib/plugins/variables.js b/lib/plugins/variables.js index 25795c408..292c9b1b2 100644 --- a/lib/plugins/variables.js +++ b/lib/plugins/variables.js @@ -1,5 +1,5 @@ -const _ = require('lodash') const Diffable = require('./diffable') +const NopCommand = require('../nopcommand') module.exports = class Variables extends Diffable { constructor (...args) { @@ -14,183 +14,106 @@ module.exports = class Variables extends Diffable { } /** - * Look-up existing variables for a given repository - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#list-repository-variables} list repository variables - * @returns {Array.} Returns a list of variables that exist in a repository - */ - async find () { + * Look up existing variables for a given repository. + * Strips API-only metadata fields (created_at, updated_at) so that + * changed() can do a clean value comparison. + * + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#list-repository-variables} + * @returns {Promise>} + */ + find () { this.log.debug(`Finding repo vars for ${this.repo.owner}/${this.repo.repo}`) - const { data: { variables } } = await this.github.request('GET /repos/:org/:repo/actions/variables', { + return this.github.request('GET /repos/:org/:repo/actions/variables', { org: this.repo.owner, repo: this.repo.repo - }) - return variables + }).then(({ data: { variables } }) => variables.map(({ name, value }) => ({ name, value }))) } /** - * Compare the existing variables with what we've defined as code - * - * @param {Array.} existing Existing variables defined in the repository - * @param {Array.} variables Variables that we have defined as code - * - * @returns {object} The results of a list comparison - */ - getChanged (existing, variables = []) { - const result = - JSON.stringify( - existing.sort((x1, x2) => { - return x1.name.toUpperCase().localeCompare(x2.name.toUpperCase()) - }) - ) !== - JSON.stringify( - variables.sort((x1, x2) => { - return x1.name.toUpperCase().localeCompare(x2.name.toUpperCase()) - }) - ) - return result - } - - /** - * Compare existing variables with what's defined - * - * @param {Object} existing The existing entries in GitHub - * @param {Object} attrs The entries defined as code - * - * @returns - */ + * Identify which existing variable matches the desired attrs by name. + * + * @param {object} existing An existing variable from the API + * @param {object} attrs A variable defined as code + * @returns {boolean} + */ comparator (existing, attrs) { return existing.name === attrs.name } /** - * Return a list of changed entries - * - * @param {Object} existing The existing entries in GitHub - * @param {Object} attrs The entries defined as code - * - * @returns - */ + * Return true if the existing variable's value differs from the desired value. + * + * @param {object} existing The existing variable from the API + * @param {object} attrs The variable defined as code + * @returns {boolean} + */ changed (existing, attrs) { - return this.getChanged(_.castArray(existing), _.castArray(attrs)) + return existing.value !== attrs.value } /** - * Update an existing variable if the value has changed - * - * @param {Array.} existing Existing variables defined in the repository - * @param {Array.} variables Variables that we have defined as code - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable} update a repository variable - * @returns - */ - async update (existing, variables = []) { - this.log.debug(`Updating a repo var existing params ${JSON.stringify(existing)} and new ${JSON.stringify(variables)}`) - existing = _.castArray(existing) - variables = _.castArray(variables) - const changed = this.getChanged(existing, variables) - - if (changed) { - let existingVariables = [...existing] - for (const variable of variables) { - const existingVariable = existingVariables.find((_var) => _var.name === variable.name) - if (existingVariable) { - existingVariables = existingVariables.filter((_var) => _var.name !== variable.name) - if (existingVariable.value !== variable.value) { - await this.github - .request('PATCH /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: variable.name.toUpperCase(), - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } - } else { - await this.github - .request('POST /repos/:org/:repo/actions/variables', { - org: this.repo.owner, - repo: this.repo.repo, - name: variable.name.toUpperCase(), - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } - } - - for (const variable of existingVariables) { - await this.github - .request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: variable.name.toUpperCase() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } + * Update an existing variable with a new value. + * + * @param {object} existing The existing variable from the API + * @param {object} attrs The desired variable state defined as code + * + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable} + * @returns {Promise} + */ + update (existing, attrs) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Update variable ${attrs.name}`) + ]) } + return this.github.request('PATCH /repos/:org/:repo/actions/variables/:variable_name', { + org: this.repo.owner, + repo: this.repo.repo, + variable_name: attrs.name.toUpperCase(), + value: attrs.value.toString() + }) } /** - * Add a new variable to a given repository - * - * @param {object} variable The variable to add, with name and value - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#create-a-repository-variable} create a repository variable - * @returns - */ - async add (variable) { - this.log.debug(`Adding a repo var with the params ${JSON.stringify(variable)}`) - await this.github - .request('POST /repos/:org/:repo/actions/variables', { - org: this.repo.owner, - repo: this.repo.repo, - name: variable.name, - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) + * Add a new variable to the repository. + * + * @param {object} attrs The variable to add, with name and value + * + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#create-a-repository-variable} + * @returns {Promise} + */ + add (attrs) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Add variable ${attrs.name}`) + ]) + } + return this.github.request('POST /repos/:org/:repo/actions/variables', { + org: this.repo.owner, + repo: this.repo.repo, + name: attrs.name.toUpperCase(), + value: attrs.value.toString() + }) } /** - * Remove variables that aren't defined as code - * - * @param {String} existing Name of the existing variable to remove - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#delete-a-repository-variable} delete a repository variable - * @returns - */ - async remove (existing) { - this.log.debug(`Removing a repo var with the params ${JSON.stringify(existing)}`) - await this.github - .request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: existing.name - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) + * Remove a variable that is no longer defined as code. + * + * @param {object} existing The existing variable to remove + * + * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#delete-a-repository-variable} + * @returns {Promise} + */ + remove (existing) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Remove variable ${existing.name}`) + ]) + } + return this.github.request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { + org: this.repo.owner, + repo: this.repo.repo, + variable_name: existing.name.toUpperCase() + }) } } diff --git a/lib/routes.js b/lib/routes.js new file mode 100644 index 000000000..6139d0b8f --- /dev/null +++ b/lib/routes.js @@ -0,0 +1,783 @@ +/** + * Router setup for Safe Settings UI & API endpoints + * Centralizes Express/Next asset & API wiring away from core app logic. + * + * Exports: + * setupRoutes(robot, getRouter) -> configured router + * + * Responsibilities: + * - Serve static exported Next.js UI (from ui/out) + * - Dashboard HTML entry points + * - JSON API endpoints + * + * This version removes dependency on robot-level cached installation getters + * (`robot.getCachedInstallations`, `robot.getOrganizationLogins`) and instead + * fetches installations live per request. If performance becomes an issue, + * a lightweight in-module memoization layer with short TTL can be reintroduced. + */ + +const path = require('path') +const util = require('util') +const fs = require('fs') +const express = require('express') +const env = require('./env') +const { getInstallations: cacheGetInstallations, getOrgLogins, getLastFetchedAt } = require('./installationCache') + +// Lightweight commit metadata cache (path+ref -> meta) with TTL to avoid +// repeated GitHub commit lookups across requests. +const COMMIT_META_TTL_MS = parseInt(process.env.COMMIT_META_TTL_MS || '300000') // 5m default +const _commitMetaCache = new Map() // key => { meta, expiresAt } +function getCachedCommitMeta (key) { + const entry = _commitMetaCache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { _commitMetaCache.delete(key); return null } + return entry.meta +} +function setCachedCommitMeta (key, meta) { + _commitMetaCache.set(key, { meta, expiresAt: Date.now() + COMMIT_META_TTL_MS }) +} + +function setupRoutes (robot, getRouter) { + // Support for URL prefix (e.g., /safe-settings) when behind a proxy + const basePath = env.SAFE_SETTINGS_HUB_URL_PREFIX || '' + const mountPath = basePath || '/' + + // Root-level mount + const router = getRouter(mountPath) + + // Ensure JSON/urlencoded body parsing is enabled for API endpoints + router.use(express.json({ limit: '1mb' })) + router.use(express.urlencoded({ extended: true })) + + // Static assets: produced by Next export/build step (ui/out) + const rootDir = path.join(__dirname, '..') // lib -> project root + const uiPath = path.join(rootDir, 'ui', 'out') + router.use(express.static(uiPath)) + + // HTML entrypoints (exported files). Adjust if you move/rename pages. + // Redirect root route to /dashboard + router.get('/', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard.html')) + }) + + router.get('/dashboard', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard.html')) + }) + + router.get('/dashboard/organizations', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'organizations.html')) + }) + + router.get('/dashboard/settings', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'settings.html')) + }) + + router.get('/dashboard/safe-settings-hub', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'safe-settings-hub.html')) + }) + + router.get('/dashboard/env', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'env.html')) + }) + + router.get('/dashboard/help', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'help.html')) + }) + + router.get('/dashboard/logs', (req, res) => { + res.sendFile(path.join(uiPath, 'dashboard', 'logs.html')) + }) + + // Apple touch icon (silence 404s). Replace file logic if you add a real 180x180 asset. + const APPLE_TOUCH_ICON_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAQAAAA9zQYyAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==' // 180x180 transparent PNG + router.get('/apple-touch-icon.png', (req, res) => { + // If a real file exists at project root, serve it; otherwise fallback to embedded transparent PNG. + const filePath = path.join(rootDir, 'apple-touch-icon.png') + fs.access(filePath, fs.constants.R_OK, (err) => { + if (!err) { + return res.sendFile(filePath) + } + const buf = Buffer.from(APPLE_TOUCH_ICON_BASE64, 'base64') + res.setHeader('Content-Type', 'image/png') + res.setHeader('Cache-Control', 'public, max-age=86400, immutable') + res.send(buf) + }) + }) + + /** + * GET /api/safe-settings/installation + * Returns live organization installation metadata + optional last commit info. + * Query param: disableActivity=true to skip commit lookups (faster). + */ + router.get('/api/safe-settings/installation', async (req, res) => { + const disableActivity = req.query.disableActivity === 'true' + const includeActivity = !disableActivity + + const crypto = require('crypto') + function hashContent (str) { + return crypto.createHash('sha256').update(str || '').digest('hex') + } + + try { + const installs = await cacheGetInstallations(robot) + const orgLogins = getOrgLogins() + const orgInstalls = installs.filter(i => i.account && i.account.type === 'Organization') + const lastCommits = {} + const syncStatus = {} + let installationDtos + + if (includeActivity && env.ADMIN_REPO) { + const orgs = orgLogins + const limit = 1 // reduce concurrency for API rate safety + const queue = [...orgs] + robot.log.info(`Starting commit and sync status fetch for ${queue} organizations...`) + + const runners = [] + const runNext = async () => { + while (queue.length) { + const org = queue.shift() + try { + const install = installs.find(i => i.account && i.account.login.toLowerCase() === org.toLowerCase()) + if (!install) { + lastCommits[org] = { na: true, hasConfigRepo: false } + syncStatus[org] = false + continue + } + const githubOrg = await robot.auth(install.id) + let hasConfigRepo = false + try { + await githubOrg.repos.get({ owner: org, repo: env.ADMIN_REPO }) + hasConfigRepo = true + } catch (repoErr) { + if (repoErr.status === 404) { + hasConfigRepo = false + } else { + robot.log.warn(`Repo existence check error for ${org}/${env.ADMIN_REPO}: ${repoErr.message}`) + } + } + // --- SYNC CHECK --- + let isInSync = false + if (hasConfigRepo) { + try { + const hubOrgDir = `${env.CONFIG_PATH}/${env.SAFE_SETTINGS_HUB_PATH}/organizations/${org}` + const hubRef = 'main' + robot.log.debug(`1. [SYNC DEBUG] Hub file path for org ${org}: ${hubOrgDir}`) + robot.log.debug(`2. [SYNC DEBUG] Hub file branch/ref for org ${org}: ${hubRef}`) + let orgFilesResp, hubFilesResp + try { + robot.log.debug(`3. [SYNC DEBUG] Org: ${org}`) + orgFilesResp = await githubOrg.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: env.CONFIG_PATH }) + const orgNames = Array.isArray(orgFilesResp.data) + ? orgFilesResp.data.map(f => f.name).join(', ') + : (orgFilesResp.data && orgFilesResp.data.name ? orgFilesResp.data.name : '') + robot.log.debug(`4. [SYNC DEBUG] Org orgFilesResp file names: ${orgNames}`) + } catch (fetchErr) { + robot.log.error(`4a. [SYNC DEBUG] Error fetching org files: ${fetchErr.message}`) + orgFilesResp = { data: [] } + } + + try { + robot.log.debug(`5. [SYNC DEBUG] Hub: ${env.SAFE_SETTINGS_HUB_ORG}`) + robot.log.debug(`5a. [SYNC DEBUG] Fetching hub files for: \n owner: ${env.SAFE_SETTINGS_HUB_ORG}, \n repo: ${env.SAFE_SETTINGS_HUB_REPO}, \n path: ${hubOrgDir}, \n ref: ${hubRef}`) + hubFilesResp = await githubOrg.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: hubOrgDir, + ref: hubRef + }) + const hubNames = Array.isArray(hubFilesResp.data) + ? hubFilesResp.data.map(f => f.name).join(', ') + : (hubFilesResp.data && hubFilesResp.data.name ? hubFilesResp.data.name : '') + robot.log.debug(`6. [SYNC DEBUG] Hub hubFilesResp file names: ${hubNames}`) + } catch (fetchErr) { + robot.log.error(`6a. [SYNC DEBUG] Error fetching hub files: ${fetchErr}`) + hubFilesResp = { data: [] } + } + + const orgFiles = Array.isArray(orgFilesResp.data) ? orgFilesResp.data.filter(f => f.type === 'file') : [] + const hubFiles = Array.isArray(hubFilesResp.data) ? hubFilesResp.data.filter(f => f.type === 'file') : ['a', 'b'] + + // Compare file names + const orgFileNames = orgFiles.map(f => f.name).sort() + const hubFileNames = hubFiles.map(f => f.name).sort() + + if (orgFileNames.length !== hubFileNames.length || orgFileNames.some((n, i) => n !== hubFileNames[i])) { + robot.log.warn(`6b. [SYNC DEBUG] File name mismatch for org ${org}`) + isInSync = false + } else { + // Compare file hashes + let allMatch = true + for (let i = 0; i < orgFiles.length; i++) { + const orgFile = orgFiles[i] + const hubFile = hubFiles[i] + robot.log.debug(`7. [SYNC DEBUG] Fetching file contents for org: ${org}, orgFile: ${orgFile.path}, hubFile: ${hubFile.path}`) + let orgContentResp, hubContentResp + try { + orgContentResp = await githubOrg.repos.getContent({ owner: org, repo: env.ADMIN_REPO, path: orgFile.path }).catch((e) => { robot.log.warn(`9. [SYNC DEBUG] Error fetching org file ${orgFile.path}: ${e.message}`); return { data: {} } }) + } catch (fetchErr) { + robot.log.error(`7a. [SYNC DEBUG] Error fetching org file ${orgFile.path}: ${fetchErr.message}`) + allMatch = false + break + } + try { + hubContentResp = await githubOrg.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: hubFile.path }).catch((e) => { robot.log.warn(`10.[SYNC DEBUG] Error fetching hub file ${hubFile.path}: ${e.message}`); return { data: {} } }) + } catch (fetchErr) { + robot.log.error(`7b. [SYNC DEBUG] Error fetching hub file ${hubFile.path}: ${fetchErr.message}`) + allMatch = false + break + } + const orgContent = orgContentResp.data.content ? Buffer.from(orgContentResp.data.content, orgContentResp.data.encoding || 'base64').toString('utf8') : '' + const hubContent = hubContentResp.data.content ? Buffer.from(hubContentResp.data.content, hubContentResp.data.encoding || 'base64').toString('utf8') : '' + const orgHash = hashContent(orgContent) + const hubHash = hashContent(hubContent) + robot.log.debug(`8. [SYNC DEBUG] Comparing file: ${orgFile.name}`) + robot.log.debug(`9. [SYNC DEBUG] Org hash: ${orgHash}`) + robot.log.debug(`10. [SYNC DEBUG] Hub hash: ${hubHash}`) + if (orgHash !== hubHash) { + robot.log.debug(`11. [SYNC DEBUG] Hash mismatch for file ${orgFile.name} in org ${org}`) + allMatch = false + break + } + } + isInSync = allMatch + } + } catch (syncErr) { + robot.log.error(`[SYNC DEBUG] Sync check error for org ${org}: ${syncErr.message}`) + isInSync = false + } + } + syncStatus[org] = isInSync + // --- END SYNC CHECK --- + // Commit info (unchanged) + let commits + try { + const pathPrefix = `${env.CONFIG_PATH.replace(/\/$/, '')}/organizations/${org}` + commits = await githubOrg.repos.listCommits({ owner: org, repo: env.ADMIN_REPO, per_page: 1, path: pathPrefix }) + } catch (err) { + if (err.status === 404) { + lastCommits[org] = { na: true, hasConfigRepo } + continue + } + if (err.status === 409) { // empty repo + lastCommits[org] = { hasConfigRepo } + continue + } + robot.log.warn(`Commit lookup error for ${org}/${env.ADMIN_REPO}: ${err.message}`) + lastCommits[org] = { hasConfigRepo } + continue + } + if (Array.isArray(commits.data) && commits.data.length) { + const c = commits.data[0] + const committedAt = (c.commit && c.commit.author && c.commit.author.date) || null + const ageSeconds = committedAt ? Math.floor((Date.now() - new Date(committedAt).getTime()) / 1000) : null + lastCommits[org] = { sha: c.sha, committed_at: committedAt, message: c.commit && c.commit.message ? c.commit.message.split('\n')[0] : null, age_seconds: ageSeconds, hasConfigRepo } + } else { + lastCommits[org] = { hasConfigRepo } + } + } catch (loopErr) { + robot.log.warn(`Unexpected error gathering commit for org ${org}: ${loopErr.message}`) + lastCommits[org] = { hasConfigRepo: false } + syncStatus[org] = false + } + } + } + for (let i = 0; i < limit; i++) runners.push(runNext()) + await Promise.all(runners) + } + + // Now that lastCommits and syncStatus are populated, build installationDtos + installationDtos = orgInstalls.map(i => { + const orgKey = i.account.login + const commitInfo = lastCommits[orgKey] || {} + return { + id: i.id, + account: orgKey, + type: i.account.type, + created_at: i.created_at, + name: orgKey, + sha: commitInfo.sha, + committed_at: commitInfo.committed_at, + message: commitInfo.message, + age_seconds: commitInfo.age_seconds, + hasConfigRepo: typeof commitInfo.hasConfigRepo === 'boolean' ? commitInfo.hasConfigRepo : false, + isInSync: typeof syncStatus[orgKey] === 'boolean' ? syncStatus[orgKey] : false + } + }) + return res.json({ updatedAt: new Date().toISOString(), installations: installationDtos }) + } catch (e) { + robot.log && robot.log.error && robot.log.error(e) + res.status(500).json({ error: e.message || 'unexpected error' }) + } + }) + + /** + * GET /api/safe-settings/hub/contents/* + * Fetches a file or directory listing from the SAFE_SETTINGS_HUB_ORG / SAFE_SETTINGS_HUB_REPO + * under the configured CONFIG_PATH (default .github). + * + * Examples: + * /api/safe-settings/hub/contents/ -> list CONFIG_PATH root + * /api/safe-settings/hub/contents/repos/foo.yml -> get specific file + * /api/safe-settings/hub/contents/repos?ref=main -> list directory at ref + * /api/safe-settings/hub/contents?recursive=true&maxDepth=2&fetchContent=false -> recursive listing without file bodies + * Note: recursive now defaults to true. Pass recursive=false for single-level listing. + */ + async function hubContent (req, res) { + let fullPath, ref + try { + // Use cached installations (TTL-based freshness) + const installs = await cacheGetInstallations(robot) + const install = installs.find(i => i.account && i.account.type === 'Organization' && i.account.login.toLowerCase() === env.SAFE_SETTINGS_HUB_ORG.toLowerCase()) + if (!install) { + return res.status(404).json({ error: `Installation for org ${env.SAFE_SETTINGS_HUB_ORG} not found` }) + } + + const github = await robot.auth(install.id) + const wildcardPath = req.params[0] || '' // from the * in the route + ref = req.query.ref || 'main' + fullPath = wildcardPath ? path.posix.join(env.CONFIG_PATH, wildcardPath) : env.CONFIG_PATH + // recursive defaults to true unless explicitly disabled with recursive=false + const recursive = req.query.recursive !== 'false' + let maxDepth = parseInt(req.query.maxDepth, 5) + if (isNaN(maxDepth) || maxDepth < 1) maxDepth = 5 // safety default + if (maxDepth > 8) maxDepth = 5 // hard cap to avoid abuse + // Unified flag: fetchContent (default true). No other legacy params supported. + const fetchContent = req.query.fetchContent !== 'false' + + // Commit metadata fetch with global shared cache + per-request memoization + const perRequestCommitCache = new Map() + const fetchCommitMeta = async (p) => { + if (perRequestCommitCache.has(p)) return perRequestCommitCache.get(p) + const cacheKey = `${ref}::${p}` + const cached = getCachedCommitMeta(cacheKey) + if (cached) { perRequestCommitCache.set(p, cached); return cached } + let meta + try { + const commits = await github.repos.listCommits({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, per_page: 1, path: p }) + .then(r => Array.isArray(r.data) ? r.data : []) + if (commits.length) { + const c = commits[0] + const committedAt = c.commit && c.commit.author && c.commit.author.date + const ageSeconds = committedAt ? Math.floor((Date.now() - new Date(committedAt).getTime()) / 1000) : null + meta = { + lastCommitSha: c.sha, + lastCommitAt: committedAt, + lastCommitMessage: c.commit && c.commit.message ? c.commit.message.split('\n')[0] : null, + lastCommitAgeSeconds: ageSeconds + } + } else { + meta = { lastCommitSha: null, lastCommitAt: null, lastCommitMessage: null, lastCommitAgeSeconds: null } + } + } catch { + meta = { lastCommitSha: null, lastCommitAt: null, lastCommitMessage: null, lastCommitAgeSeconds: null } + } + setCachedCommitMeta(cacheKey, meta) + perRequestCommitCache.set(p, meta) + return meta + } + + // Helper to fetch a single file (returns null on failure) + const fetchFile = async (p) => { + try { + const fileResp = await github.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: p, ref }) + if (Array.isArray(fileResp.data)) return null + // file + const commitMeta = await fetchCommitMeta(fileResp.data.path) + if (fetchContent && typeof fileResp.data.content === 'string') { + const decoded = Buffer.from(fileResp.data.content, fileResp.data.encoding || 'base64').toString('utf8') + return { + type: fileResp.data.type, + name: path.posix.basename(p), + path: fileResp.data.path, + sha: fileResp.data.sha, + size: fileResp.data.size, + encoding: 'utf8', + content: decoded, + originalEncoding: fileResp.data.encoding || 'base64', + ...commitMeta + } + } + // metadata-only response + return { + type: fileResp.data.type, + name: path.posix.basename(p), + path: fileResp.data.path, + sha: fileResp.data.sha, + size: fileResp.data.size, + content: null, + originalEncoding: fileResp.data.encoding || 'base64', + ...commitMeta + } + } catch (e) { + robot.log.warn(`Failed to fetch file ${p}: ${e.message}`) + return null + } + } + + // Recursive traversal with depth limiting and basic cycle protection + const seen = new Set() + // Concurrency limiter for directory entry processing + const MAX_DIR_CONCURRENCY = parseInt(process.env.DIR_ENTRY_CONCURRENCY || '6') + async function mapWithLimit (items, mapper) { + const out = [] + let i = 0 + const running = new Set() + async function run () { + if (i >= items.length) return + const idx = i++ + const p = Promise.resolve(mapper(items[idx], idx)).then(r => { out[idx] = r; running.delete(p) }) + running.add(p) + if (running.size >= MAX_DIR_CONCURRENCY) await Promise.race(running) + return run() + } + await run() + await Promise.all([...running]) + return out + } + + const traverseDir = async (dirPath, depth = 0) => { + if (depth >= maxDepth) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, truncated: true, entries: [], ...commitMeta } + } + if (seen.has(dirPath)) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, cycle: true, entries: [], ...commitMeta } + } + seen.add(dirPath) + let listing + try { + const resp = await github.repos.getContent({ owner: env.SAFE_SETTINGS_HUB_ORG, repo: env.SAFE_SETTINGS_HUB_REPO, path: dirPath, ref }) + if (!Array.isArray(resp.data)) { + // Not a directory; fetch as file instead + const f = await fetchFile(dirPath) + return f || { type: 'file', path: dirPath, error: 'unreadable' } + } + listing = resp.data + } catch (e) { + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, error: e.status === 404 ? 'not_found' : e.message, entries: [], ...commitMeta } + } + + const entries = await mapWithLimit(listing, async (item) => { + if (item.type === 'file') { + if (fetchContent) { + const f = await fetchFile(item.path) + if (f) return f + const commitMeta = await fetchCommitMeta(item.path) + return { type: 'file', name: item.name, path: item.path, sha: item.sha, size: item.size, content: null, ...commitMeta } + } + const commitMeta = await fetchCommitMeta(item.path) + return { type: 'file', name: item.name, path: item.path, sha: item.sha, size: item.size, content: null, ...commitMeta } + } else if (item.type === 'dir') { + return traverseDir(item.path, depth + 1) + } + const commitMeta = await fetchCommitMeta(item.path) + return { type: item.type, name: item.name, path: item.path, unsupported: true, ...commitMeta } + }) + const commitMeta = await fetchCommitMeta(dirPath) + return { type: 'dir', name: path.posix.basename(dirPath), path: dirPath, depth, entries, ...commitMeta } + } + + let response + try { + response = await github.repos.getContent({ + owner: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fullPath, + ref + }) + } catch (apiError) { + robot.log.error(`GitHub API error details: status=${apiError.status}, message=${apiError.message}`) + if (apiError.response && apiError.response.data) { + robot.log.error(`GitHub API response: ${JSON.stringify(apiError.response.data)}`) + } + throw apiError + } + + const data = response.data + if (Array.isArray(data)) { + if (recursive) { + const tree = await traverseDir(fullPath, 0) + return res.json({ + recursive: true, + maxDepth, + ref, + fetchContent, + ...tree + }) + } else { + // non-recursive (original behavior) + const entries = await Promise.all(data.map(async d => { + if (d.type === 'file') { + if (fetchContent) { + const f = await fetchFile(d.path) + if (f) return f + } + return { + name: d.name, + path: d.path, + type: d.type, + sha: d.sha, + size: d.size, + content: null + } + } + return { + name: d.name, + path: d.path, + type: d.type, + sha: d.sha, + size: d.size, + content: null + } + })) + return res.json({ + type: 'dir', + path: fullPath, + entries, + ref, + fetchContent + }) + } + } + + if (typeof data.content === 'string') { + if (fetchContent) { + const decoded = Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + return res.json({ + type: data.type, + path: data.path, + sha: data.sha, + size: data.size, + encoding: 'utf8', + content: decoded, + originalEncoding: data.encoding || 'base64', + ref, + fetchContent: true + }) + } + return res.json({ + type: data.type, + path: data.path, + sha: data.sha, + size: data.size, + content: null, + ref, + fetchContent: false + }) + } + // Unsupported type (symlink, submodule, etc.) + return res.status(415).json({ error: 'Unsupported content type returned by GitHub API' }) + } catch (e) { + if (e.status === 404) { + robot.log.error(`Hub content 404: ${env.SAFE_SETTINGS_HUB_ORG}/${env.SAFE_SETTINGS_HUB_REPO} path=${fullPath} ref=${ref}`) + return res.status(404).json({ + error: 'Not found', + details: { + org: env.SAFE_SETTINGS_HUB_ORG, + repo: env.SAFE_SETTINGS_HUB_REPO, + path: fullPath, + ref + } + }) + } + robot.log && robot.log.error && robot.log.error(e) + return res.status(500).json({ error: e.message || 'unexpected error' }) + } + } + + router.get('/api/safe-settings/hub/content', hubContent) + router.get('/api/safe-settings/hub/content/*', hubContent) + + /** + * GET /api/safe-settings/app/env + * Returns key/value pairs parsed from the project .env file excluding + * standard GitHub App infrastructure variables. + * Query params: + * includeInfra=true -> include normally excluded infrastructure vars + */ + router.get('/api/safe-settings/app/env', (req, res) => { + try { + // Define a blacklist of sensitive environment variable keys to exclude + const ENV_BLACKLIST = ['PRIVATE_KEY_PATH']; + + // Parse .env file to extract comments + const envFilePath = path.join(rootDir, '.env'); + const commentMap = new Map(); + + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf8'); + const lines = envFileContent.split(/\r?\n/); + + lines.forEach(line => { + // Match lines like: KEY=value # comment + const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*[^#]*#\s*(.+)$/); + if (match) { + const [, key, comment] = match; + commentMap.set(key, comment.trim()); + } + }); + } catch (e) { + // If .env file doesn't exist or can't be read, continue without comments + robot.log && robot.log.debug && robot.log.debug('Could not read .env file for comments:', e.message); + } + + const variables = Object.entries(env) + .filter(([key]) => !ENV_BLACKLIST.includes(key)) + .map(([key, value]) => ({ + key, + value, + description: commentMap.get(key) || 'NA' + })) + .sort((a, b) => a.key.localeCompare(b.key)); + return res.json({ updatedAt: new Date().toISOString(), count: variables.length, variables }); + } catch (e) { + robot.log && robot.log.error && robot.log.error(e); + return res.status(500).json({ error: e.message || 'unexpected error' }); + } + }) + + + // POST /api/safe-settings/hub/import + // Body: { orgs: ['org1','org2'] } + router.post('/api/safe-settings/hub/import', async (req, res) => { + try { + const body = req.body || {} + const orgs = Array.isArray(body.orgs) ? body.orgs : (body.org ? [body.org] : null) + if (!orgs || !orgs.length) { + return res.status(400).json({ error: 'Missing orgs in request body. Expected JSON { orgs: ["org1","org2"] }' }) + } + // lazy-require to avoid circular require issues during module load + const { retrieveSettingsFromOrgs } = require('./hubSyncHandler') + const results = await retrieveSettingsFromOrgs(robot, orgs) + // Always return 200 with results, even if some/all orgs failed + return res.json({ ok: true, results }) + } catch (e) { + robot.log && robot.log.error && robot.log.error(e) + // Return 200 with error indicator for UI instead of 500 + return res.status(200).json({ ok: false, error: e.message || 'unexpected error', results: [] }) + } + }) + + + // GET /api/safe-settings/hub/log + // Returns parsed log entries (JSON): [{ timestamp, level, message }, ...] + router.get('/api/safe-settings/hub/log', async (req, res) => { + const lines = parseInt(req.query.lines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || '1000', 10) + const levelsQuery = req.query.levels // comma-separated e.g. 'ERROR,WARN' + const allowedLevels = levelsQuery ? new Set(String(levelsQuery).split(',').map(s => s.trim().toUpperCase()).filter(Boolean)) : null + + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(rootDir, 'hubSyncHandler.log')) // Primary log file for hub-sync + candidates.push(path.join(rootDir, 'safe-settings.log')) + candidates.push(path.join(rootDir, '..', 'safe-settings.log')) + candidates.push(path.join(rootDir, 'ui', 'safe-settings.log')) + + let found = null + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.promises.stat(p) + if (st && st.isFile()) { found = p; break } + } catch (e) { + // ignore + } + } + if (!found) return res.status(404).json({ error: 'Log file not found' }) + + try { + const data = await fs.promises.readFile(found, 'utf8') + const arr = data.split(/\r?\n/).filter(Boolean) + const tail = arr.slice(-lines) + const parsed = tail.map(line => { + // Expecting format: 2025-09-10T12:34:56.789Z [INFO] message + const m = line.match(/^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+\[([A-Z]+)\]\s+(.*)$/) + if (m) { + return { timestamp: m[1], level: m[2], message: m[3], raw: line } + } + // fallback: try to extract level in brackets + const m2 = line.match(/\[([A-Z]+)\]\s*(.*)$/) + if (m2) return { timestamp: null, level: m2[1], message: m2[2], raw: line } + return { timestamp: null, level: 'UNKNOWN', message: line, raw: line } + }) + const filtered = allowedLevels ? parsed.filter(p => allowedLevels.has(String(p.level).toUpperCase())) : parsed + return res.json({ count: filtered.length, entries: filtered }) + } catch (err) { + return res.status(500).json({ error: err && err.message ? err.message : String(err) }) + } + }) + + // Alias for backwards compatibility + router.get('/api/safe-settings/logs', (req, res, next) => { + req.url = '/api/safe-settings/hub/log' + (req._parsedUrl.search || '') + next('route') + }) + + // GET /api/safe-settings/sync-logs + // Returns only sync-related log entries (filtered by [SYNC or containing 'sync') + router.get('/api/safe-settings/sync-logs', async (req, res) => { + const lines = parseInt(req.query.lines || process.env.SAFE_SETTINGS_LOG_FILE_MAX_LINES || '1000', 10) + const levelsQuery = req.query.levels // comma-separated e.g. 'ERROR,WARN' + const allowedLevels = levelsQuery ? new Set(String(levelsQuery).split(',').map(s => s.trim().toUpperCase()).filter(Boolean)) : null + + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(rootDir, 'hubSyncHandler.log')) // Primary log file for hub-sync + candidates.push(path.join(rootDir, 'safe-settings.log')) + candidates.push(path.join(rootDir, '..', 'safe-settings.log')) + candidates.push(path.join(rootDir, 'ui', 'safe-settings.log')) + + let found = null + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.promises.stat(p) + if (st && st.isFile()) { found = p; break } + } catch (e) { + // ignore + } + } + if (!found) return res.status(404).json({ error: 'Log file not found' }) + + try { + const data = await fs.promises.readFile(found, 'utf8') + const arr = data.split(/\r?\n/).filter(Boolean) + const tail = arr.slice(-lines * 3) // Get more lines to ensure we have enough sync entries + const parsed = tail.map(line => { + // Expecting format: 2025-09-10T12:34:56.789Z [INFO] message + const m = line.match(/^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+\[([A-Z]+)\]\s+(.*)$/) + if (m) { + return { timestamp: m[1], level: m[2], message: m[3], raw: line } + } + // fallback: try to extract level in brackets + const m2 = line.match(/\[([A-Z]+)\]\s*(.*)$/) + if (m2) return { timestamp: null, level: m2[1], message: m2[2], raw: line } + return { timestamp: null, level: 'UNKNOWN', message: line, raw: line } + }) + + // Filter for sync-related entries + const syncFiltered = parsed.filter(p => { + const msg = p.message.toLowerCase() + return msg.includes('sync') || msg.includes('[sync') + }) + + // Apply level filter if specified + const filtered = allowedLevels + ? syncFiltered.filter(p => allowedLevels.has(String(p.level).toUpperCase())) + : syncFiltered + + // Limit to requested number of lines + const final = filtered.slice(-lines) + + return res.json({ count: final.length, entries: final, syncOnly: true }) + } catch (err) { + return res.status(500).json({ error: err && err.message ? err.message : String(err) }) + } + }) + + return router +} + +module.exports = { setupRoutes } diff --git a/lib/settings.js b/lib/settings.js index 6c42e439b..a9a2deb67 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -6,22 +6,564 @@ const Glob = require('./glob') const NopCommand = require('./nopcommand') const MergeDeep = require('./mergeDeep') const Archive = require('./plugins/archive') +const DeploymentConfig = require('./deploymentConfig') const env = require('./env') + +// Valid `target` values for a disable_plugins entry. +const DISABLE_TARGETS = new Set(['self', 'children', 'all']) +// Valid declaration layers (where a disable_plugins entry can be authored). +const DISABLE_LEVELS = ['deployment', 'org', 'suborg', 'repo'] +// For each declared layer + target, the set of layers from which to STRIP the +// named plugin's config. See plan-v3 matrix. +const DISABLE_STRIP_MATRIX = { + deployment: { + self: ['deployment'], + children: ['org', 'suborg', 'repo'], + all: ['deployment', 'org', 'suborg', 'repo'] + }, + org: { + self: ['org'], + children: ['suborg', 'repo'], + all: ['org', 'suborg', 'repo'] + }, + suborg: { + self: ['suborg'], + children: ['repo'], + all: ['suborg', 'repo'] + }, + repo: { + self: ['repo'], + children: ['repo'], // normalized; repo has no children + all: ['repo'] + } +} const CONFIG_PATH = env.CONFIG_PATH const eta = new Eta({ views: path.join(__dirname) }) const SCOPE = { ORG: 'org', REPO: 'repo' } // Determine if the setting is a org setting or repo setting +// Maximum size (in characters) of a single PR comment / check-run summary body. +const COMMENT_LIMIT = 55536 const yaml = require('js-yaml') +// When a repo-yml change applies teams/properties/etc to a repo, the repo may +// change suborg config matches (via suborgteams/suborgproperties/suborgrepos). +// Re-run updateRepos for the same repo at most this many times. Depth=1 is the +// tightest cap: we resolve a single hop of newly-matched suborg per sync. +const MAX_REEVALUATION_DEPTH = 1 + +// --------------------------------------------------------------------------- +// NOP change-detection helpers +// --------------------------------------------------------------------------- + +// Recursively determines whether a value is "empty" (null/undefined, empty +// array/object, or a structure containing only empty values). +function isDeepEmpty (value) { + if (value === null || value === undefined) return true + if (Array.isArray(value)) return value.length === 0 || value.every(isDeepEmpty) + if (typeof value === 'object') { + const keys = Object.keys(value) + return keys.length === 0 || keys.every(k => isDeepEmpty(value[k])) + } + return false +} + +// Determines whether a NopCommand action represents no meaningful change. +// String actions (message-only NOP results) are treated as non-empty so they +// are not silently dropped from reporting. +function isEmptyChange (action) { + if (!action) return true + if (typeof action === 'string') return action.length === 0 + const { additions, deletions, modifications } = action + if (additions === null && deletions === null && modifications === null) return true + return isDeepEmpty(additions) && isDeepEmpty(deletions) && isDeepEmpty(modifications) +} + +// Produce a canonical (key-sorted) clone so deep equality is order-independent. +function canonicalize (value) { + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(canonicalize) + return Object.keys(value).sort().reduce((acc, key) => { + acc[key] = canonicalize(value[key]) + return acc + }, {}) +} + +function stableStringify (value) { + return JSON.stringify(canonicalize(value)) +} + +/** + * Determines which named entries in an array-based config section actually + * changed between the base branch and the PR branch. Returns a Set of entry + * names that differ. Uses name-indexed Maps (O(n)) and order-independent deep + * equality to avoid false positives from key ordering. + */ +function getChangedEntryNames (baseEntries, prEntries) { + const changed = new Set() + if (!baseEntries && !prEntries) return changed + if (!baseEntries || !Array.isArray(baseEntries)) { + // All PR entries are new + if (Array.isArray(prEntries)) prEntries.forEach(e => { if (e && e.name) changed.add(e.name) }) + return changed + } + if (!prEntries || !Array.isArray(prEntries)) { + // All base entries are deleted + baseEntries.forEach(e => { if (e && e.name) changed.add(e.name) }) + return changed + } + const baseByName = new Map() + baseEntries.forEach(e => { if (e && e.name) baseByName.set(e.name, e) }) + const prByName = new Map() + prEntries.forEach(e => { if (e && e.name) prByName.set(e.name, e) }) + // Added or modified entries + for (const [name, prEntry] of prByName) { + const baseEntry = baseByName.get(name) + if (!baseEntry || stableStringify(baseEntry) !== stableStringify(prEntry)) { + changed.add(name) + } + } + // Deleted entries + for (const name of baseByName.keys()) { + if (!prByName.has(name)) changed.add(name) + } + return changed +} + +/** + * Filters a NOP action's arrays to only include entries whose 'name' is in the + * changedNames set. Returns a new action with filtered arrays, or null if + * nothing meaningful remains. + */ +function filterActionByChangedNames (action, changedNames) { + if (!action || typeof action === 'string') return action + + const { additions, deletions, modifications, ...rest } = action + + const filterArray = (arr) => { + if (!arr || !Array.isArray(arr)) return arr + return arr.filter(entry => { + if (!entry || typeof entry !== 'object') return true + // Keep entries whose name is in the changed set + if (entry.name && changedNames.has(entry.name)) return true + // Keep entries without a name field (structural entries like conditions) + if (!entry.name) return true + return false + }) + } + + const filtered = { + ...rest, + additions: filterArray(additions), + deletions: filterArray(deletions), + modifications: filterArray(modifications) + } + + // Return null if everything was filtered out + if (isEmptyChange(filtered)) return null + return filtered +} + +// --------------------------------------------------------------------------- +// NOP change-rendering helpers (collapsible, field-level diff summaries) +// --------------------------------------------------------------------------- + +function buildChangeSections (changes, baseConfig, config) { + return Object.keys(changes).map(plugin => { + const repoSections = [] + Object.keys(changes[plugin]).forEach(repo => { + const targetMap = new Map() + changes[plugin][repo].forEach(action => { + targetsForAction(plugin, repo, action, baseConfig, config).forEach(target => { + if (!targetMap.has(target.target)) { + targetMap.set(target.target, { + target: target.target, + rows: [] + }) + } + targetMap.get(target.target).rows.push(...target.rows) + }) + }) + repoSections.push({ + repo, + targets: Array.from(targetMap.values()).filter(target => target.rows.length > 0) + }) + }) + + const filteredRepoSections = repoSections.filter(repoSection => repoSection.targets.length > 0) + const changeCount = filteredRepoSections.reduce((count, repoSection) => { + return count + repoSection.targets.reduce((targetCount, target) => targetCount + target.rows.length, 0) + }, 0) + const targetCount = filteredRepoSections.reduce((count, repoSection) => count + repoSection.targets.length, 0) + const repoCount = filteredRepoSections.length + const targetSingular = plugin.toLowerCase() === 'rulesets' ? 'policy' : 'setting' + const targetPlural = plugin.toLowerCase() === 'rulesets' ? 'policies' : 'settings' + const impactSummary = `${repoCount} ${pluralize(repoCount, 'repo', 'repos')}, ${targetCount} ${pluralize(targetCount, targetSingular, targetPlural)} changed` + return { + plugin, + repoSections: filteredRepoSections, + repoCount, + targetCount, + changeCount, + impactSummary, + summary: `${plugin} - ${impactSummary}` + } + }).filter(section => section.repoSections.length > 0) +} + +function renderChangeSections (changeSections) { + return changeSections.map(section => { + const repoBlocks = section.repoSections.map(repoSection => { + const targetBlocks = repoSection.targets.map(target => { + return `- ${markdownInlineCode(target.target)}\n${renderFieldChangeList(target.rows, ' ')}` + }) + return `**${markdownText(displayRepoName(repoSection.repo))}**\n${targetBlocks.join('\n')}` + }) + + return `
\n${escapeHtml(section.plugin)} — ${escapeHtml(section.impactSummary)}\n\n${repoBlocks.join('\n\n')}\n\n
` + }) +} + +function affectedRepoCount (changeSections) { + return new Set(changeSections.flatMap(section => { + return section.repoSections.map(repoSection => displayRepoName(repoSection.repo)) + })).size +} + +function displayRepoName (repo) { + return repo && repo.endsWith('(org)') ? env.ADMIN_REPO : repo +} + +function renderFieldChangeList (rows, indent = '') { + return rows.map(row => { + const marker = changeMarker(row.change) + if (row.change === 'Info') { + return `${indent}- ${marker} ${markdownText(row.after || row.before || row.field)}` + } + if (row.change === 'Modified') { + return `${indent}- ${marker} ${markdownInlineCode(row.field)}\n${indent} - before: ${markdownInlineCode(row.before, row.after)}\n${indent} - after: ${markdownInlineCode(row.after, row.before)}` + } + const value = row.change === 'Deleted' ? row.before : row.after + return `${indent}- ${marker} ${markdownInlineCode(row.field)}: ${markdownInlineCode(value)}` + }).join('\n') +} + +function changeMarker (change) { + if (change === 'Added') return '+' + if (change === 'Deleted') return '-' + if (change === 'Modified') return '~' + return 'i' +} + +function targetsForAction (plugin, repo, action, baseConfig, config) { + if (typeof action === 'string') { + return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action)])] + } + + const configTargets = targetsFromConfigDiff(plugin, repo, action, baseConfig, config) + if (configTargets) return configTargets + + const additions = normalizeChangeEntries(action && action.additions) + const deletions = normalizeChangeEntries(action && action.deletions) + const modifications = normalizeChangeEntries(action && action.modifications) + + const usedDeletions = new Set() + const targets = [] + + additions.forEach(entry => { + const target = getChangeTarget(entry, plugin) + targets.push(createTarget(target, rowsForAddedOrDeleted('Added', entry, target))) + }) + + modifications.forEach((entry, index) => { + const target = getChangeTarget(entry, plugin) + const match = findMatchingDeletion(entry, index, modifications, deletions, usedDeletions) + if (match.index !== -1) usedDeletions.add(match.index) + targets.push(createTarget(target, rowsForModification(match.entry, entry, target))) + }) + + deletions.forEach((entry, index) => { + if (usedDeletions.has(index)) return + const target = getChangeTarget(entry, plugin) + targets.push(createTarget(target, rowsForAddedOrDeleted('Deleted', entry, target))) + }) + + if (targets.length === 0 && action && action.msg) { + return [createTarget(plugin, [createFieldChangeRow('Info', 'message', '', action.msg)])] + } + + return targets +} + +function targetsFromConfigDiff (plugin, repo, action, baseConfig, config) { + if (!baseConfig || !config || !action || typeof action === 'string') return null + + const pluginSection = plugin.toLowerCase() + const isOrgRulesets = repo && repo.endsWith('(org)') && pluginSection === 'rulesets' + const baseEntries = baseConfig[pluginSection] + const prEntries = config[pluginSection] + + if (!isOrgRulesets) return null + if (!Array.isArray(baseEntries) || !Array.isArray(prEntries)) return null + + const actionNames = getActionEntryNames(action) + if (actionNames.size === 0) return null + + const changedNames = new Set(Array.from(getChangedEntryNames(baseEntries, prEntries)).filter(name => actionNames.has(name))) + if (changedNames.size === 0) return null + + const targets = [] + Array.from(changedNames).sort().forEach(name => { + const oldEntry = findEntryByIdentity(baseEntries, name) + const newEntry = findEntryByIdentity(prEntries, name) + let rows = [] + + if (oldEntry && newEntry) { + rows = rowsForModification(oldEntry, newEntry, name) + } else if (newEntry) { + rows = rowsForAddedOrDeleted('Added', newEntry, name) + } else if (oldEntry) { + rows = rowsForAddedOrDeleted('Deleted', oldEntry, name) + } + + if (rows.length > 0) targets.push(createTarget(name, rows)) + }) + + return targets.length > 0 ? targets : null +} + +function getActionEntryNames (action) { + const names = new Set() + ;['additions', 'deletions', 'modifications'].forEach(actionField => { + normalizeChangeEntries(action[actionField]).forEach(entry => { + const identity = getEntryIdentityValue(entry) + if (identity) names.add(identity) + }) + }) + return names +} + +function findEntryByIdentity (entries, identity) { + return entries.find(entry => getEntryIdentityValue(entry) === identity) +} + +function createTarget (target, rows) { + return { + target, + rows: rows.filter(row => row) + } +} + +function normalizeChangeEntries (value) { + if (isDeepEmpty(value)) return [] + return Array.isArray(value) ? value.filter(entry => !isDeepEmpty(entry)) : [value] +} + +function findMatchingDeletion (entry, index, modifications, deletions, usedDeletions) { + const identity = getChangeIdentity(entry) + if (identity) { + const matchIndex = deletions.findIndex((deletion, deletionIndex) => { + if (usedDeletions.has(deletionIndex)) return false + return getChangeIdentity(deletion) === identity + }) + if (matchIndex !== -1) return { entry: deletions[matchIndex], index: matchIndex } + } + + if (modifications.length === 1 && deletions.length === 1 && !usedDeletions.has(0)) { + return { entry: deletions[0], index: 0 } + } + + return { entry: null, index: -1 } +} + +function getChangeIdentity (entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null + const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) + if (!field) return null + return `${field}:${formatValue(entry[field]).text}` +} + +function getChangeTarget (entry, fallback) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return formatValue(entry).text || fallback + return getEntryIdentityValue(entry) || fallback +} + +function getEntryIdentityValue (entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null + const field = MergeDeep.NAME_FIELDS.find(field => Object.prototype.hasOwnProperty.call(entry, field)) + return field ? formatValue(entry[field]).text : null +} + +function rowsForAddedOrDeleted (change, entry, target) { + const flattened = flattenForSummary(entry, true) + const fields = Object.keys(flattened) + if (fields.length === 0) return [createFieldChangeRow(change, 'value', change === 'Added' ? '' : target, change === 'Added' ? target : '')] + + return fields.map(path => { + const value = flattened[path] + return createFieldChangeRow(change, path, change === 'Deleted' ? value : '', change === 'Deleted' ? '' : value) + }) +} + +function rowsForModification (oldEntry, newEntry, target) { + if (!oldEntry || typeof oldEntry !== 'object' || !newEntry || typeof newEntry !== 'object') { + return rowsForAddedOrDeleted('Modified', newEntry, target) + } + + const oldPaths = flattenForSummary(oldEntry, true) + const newPaths = flattenForSummary(newEntry, true) + const paths = Array.from(new Set([...Object.keys(oldPaths), ...Object.keys(newPaths)])).sort() + const rows = paths.map(path => { + const hasOld = Object.prototype.hasOwnProperty.call(oldPaths, path) + const hasNew = Object.prototype.hasOwnProperty.call(newPaths, path) + if (hasOld && hasNew && comparableValue(oldPaths[path]) !== comparableValue(newPaths[path])) { + return createFieldChangeRow('Modified', path, oldPaths[path], newPaths[path]) + } + if (!hasOld && hasNew) { + return createFieldChangeRow('Added', path, '', newPaths[path]) + } + if (hasOld && !hasNew) { + return createFieldChangeRow('Deleted', path, oldPaths[path], '') + } + return null + }).filter(row => row) + + if (rows.length > 0) return rows + return rowsForAddedOrDeleted('Modified', newEntry, target) +} + +function createFieldChangeRow (change, field, before, after) { + return { + change, + field, + before, + after + } +} + +function flattenForSummary (value, skipRootIdentity = false, prefix = '') { + if (value === null || value === undefined || typeof value !== 'object') { + return { [prefix || 'value']: formatValue(value) } + } + + if (Array.isArray(value)) { + return { [prefix || 'value']: formatValue(value) } + } + + const result = {} + Object.keys(value).forEach(key => { + if (!prefix && skipRootIdentity && MergeDeep.NAME_FIELDS.includes(key)) return + const path = prefix ? `${prefix}.${key}` : key + const child = value[key] + + if (child && typeof child === 'object' && !Array.isArray(child)) { + Object.assign(result, flattenForSummary(child, false, path)) + } else { + result[path] = formatValue(child) + } + }) + + return result +} + +function formatValue (value) { + if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) return value + if (value === null) return { text: 'null', compare: 'null' } + if (value === undefined) return { text: '', compare: '' } + if (typeof value === 'string') return { text: value, compare: value } + if (typeof value === 'number' || typeof value === 'boolean') return { text: `${value}`, compare: `${value}` } + if (Array.isArray(value) && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) { + const text = value.map(item => formatValue(item).text).join(', ') + return { text, compare: text } + } + const json = JSON.stringify(value) + return { + text: truncate(json, 180), + compare: json + } +} + +function comparableValue (value) { + const displayValue = formatValue(value) + return Object.prototype.hasOwnProperty.call(displayValue, 'compare') ? displayValue.compare : displayValue.text +} + +function truncate (value, limit = 180) { + if (!value || value.length <= limit) return value + return `${value.substring(0, limit - 3)}...` +} + +function truncateAroundDifference (value, otherValue, limit = 180) { + if (!value || value.length <= limit) return value + if (!otherValue || value === otherValue) return truncate(value, limit) + + let prefixLength = 0 + while ( + prefixLength < value.length && + prefixLength < otherValue.length && + value[prefixLength] === otherValue[prefixLength] + ) { + prefixLength++ + } + + let suffixLength = 0 + while ( + suffixLength < value.length - prefixLength && + suffixLength < otherValue.length - prefixLength && + value[value.length - 1 - suffixLength] === otherValue[otherValue.length - 1 - suffixLength] + ) { + suffixLength++ + } + + const contextLength = Math.floor((limit - 6) / 2) + const start = Math.max(0, prefixLength - contextLength) + const end = Math.min(value.length, value.length - suffixLength + contextLength) + const prefix = start > 0 ? '...' : '' + const suffix = end < value.length ? '...' : '' + return truncate(`${prefix}${value.substring(start, end)}${suffix}`, limit) +} + +function truncateWithSuffix (value, limit, suffix) { + if (!value || value.length <= limit) return value + return `${value.substring(0, limit - suffix.length)}${suffix}` +} + +function pluralize (count, singular, plural) { + return count === 1 ? singular : plural +} + +function markdownInlineCode (value, comparedWith) { + return `\`${markdownText(value, comparedWith).replaceAll('`', '\\`')}\`` +} + +function markdownText (value, comparedWith) { + const displayValue = formatValue(value) + const otherDisplayValue = comparedWith === undefined ? null : formatValue(comparedWith) + const text = otherDisplayValue + ? truncateAroundDifference(displayValue.compare || displayValue.text, otherDisplayValue.compare || otherDisplayValue.text) + : displayValue.text + return escapeHtml(text) + .replaceAll('\n', ' ') +} + +function escapeHtml (value) { + return `${value}` + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + class Settings { static fileCache = {} - static async syncAll (nop, context, repo, config, ref) { - const settings = new Settings(nop, context, repo, config, ref) + static async syncAll (nop, context, repo, config, ref, baseConfig, changedFiles = {}) { + const settings = new Settings(nop, context, repo, config, ref, null, baseConfig) + settings.setChangedConfigTargets(changedFiles.repos, changedFiles.subOrgs) try { await settings.loadConfigs() + settings.trackChangedReposFromSubOrgConfigs() // settings.repoConfigs = await settings.getRepoConfigs() await settings.updateOrg() await settings.updateAll() + await settings.updateChangedRepoConfigs(changedFiles.repos) await settings.handleResults() } catch (error) { settings.logError(error.message) @@ -42,9 +584,74 @@ class Settings { } } + static async syncSelectedRepos (nop, context, repos, subOrgs, config, ref, baseConfig, baseRef) { + const settings = new Settings(nop, context, context.repo(), config, ref, null, baseConfig) + settings.setChangedConfigTargets(repos, subOrgs) + + try { + // Track repos affected by changed suborg config files so base-config + // filtering knows which repo-level results to keep during NOP runs. + settings.subOrgConfigs = await settings.getSubOrgConfigs() + settings.trackChangedReposFromSubOrgConfigs() + + // Identify repos removed from suborg targeting due to targeting rule + // changes in the suborg config file. These repos need processing so + // their suborg-applied settings (e.g. rulesets) are cleaned up. + if (subOrgs.length > 0 && baseRef) { + const removedRepos = await settings.getReposRemovedFromSubOrgTargeting(subOrgs, baseRef) + if (removedRepos.length > 0) { + settings.log.debug(`Repos removed from suborg targeting: ${JSON.stringify(removedRepos)}`) + // Add removed repos to changedRepoNames so NOP filtering keeps their results + if (!settings.changedRepoNames) { + settings.changedRepoNames = new Set() + } + for (const repoName of removedRepos) { + settings.changedRepoNames.add(repoName) + } + // Process removed repos with org-only config (no suborg layer) + for (const repoName of removedRepos) { + if (settings.isRestricted(repoName)) continue + if (settings.processedRepoNames.has(repoName)) continue + const repo = { owner: context.repo().owner, repo: repoName } + settings.repoConfigs = await settings.getRepoConfigs(repo) + await settings.updateRepos(repo) + } + } + } + + // Re-eval is enabled only for the per-repo iteration (repo-yml change + // path). The trailing suborg iteration below already iterates all suborg + // repos, so it is left with the flag off. + settings.reevaluateOnChange = true + for (const repo of repos) { + settings.repo = repo + await settings.loadConfigs(repo) + if (settings.isRestricted(repo.repo)) { + continue + } + await settings.updateRepos(repo) + } + settings.reevaluateOnChange = false + for (const suborg of subOrgs) { + settings.subOrgConfigMap = [suborg] + settings.suborgChange = !!suborg + await settings.loadConfigs() + await settings.updateAll() + } + await settings.handleResults() + } catch (error) { + settings.logError(error.message) + await settings.handleResults() + } + } + static async sync (nop, context, repo, config, ref) { const settings = new Settings(nop, context, repo, config, ref) try { + // Repo-yml change path: re-evaluate suborg membership for this repo if + // the applied changes (teams/custom_properties/new repo) cause it to + // newly match a suborg config. + settings.reevaluateOnChange = true await settings.loadConfigs(repo) if (settings.isRestricted(repo.repo)) { return @@ -63,13 +670,14 @@ class Settings { await settings.handleResults() } - constructor (nop, context, repo, config, ref, suborg) { + constructor (nop, context, repo, config, ref, suborg, baseConfig) { this.ref = ref this.context = context this.installation_id = context.payload.installation.id this.github = context.octokit this.repo = repo this.config = config + this.baseConfig = baseConfig || null this.nop = nop this.suborgChange = !!suborg // If suborg config has been updated, do not load the entire suborg config, and only process repos restricted to it. @@ -99,6 +707,150 @@ class Settings { } } this.mergeDeep = new MergeDeep(this.log, this.github, [], this.configvalidators, this.overridevalidators) + // Suborg re-evaluation state (used only when reevaluateOnChange is true). + // - reevaluationDepth: repo name -> number of re-evaluation passes done. + // - reevaluatedRepos: repo name -> set of suborg source paths seen so far + // (used for stability comparison; if no new sources appear, we stop). + this.reevaluateOnChange = false + this.reevaluationDepth = new Map() + this.reevaluatedRepos = new Map() + this.processedRepoNames = new Set() + } + + // Record which repo override files and suborg config files changed in the PR. + // Used during NOP runs to keep repo-level results whose config actually + // changed (and filter out pre-existing drift). + setChangedConfigTargets (changedRepos = [], changedSubOrgs = []) { + const repoNames = Array.isArray(changedRepos) + ? changedRepos.map(repo => repo && repo.repo).filter(Boolean) + : [] + + this.changedRepoNames = new Set(repoNames) + this.changedSubOrgConfigs = Array.isArray(changedSubOrgs) ? changedSubOrgs : [] + } + + // Expand changedSubOrgConfigs (changed suborg config files) into the set of + // repos they affect, adding them to changedRepoNames. + trackChangedReposFromSubOrgConfigs () { + if (!Array.isArray(this.changedSubOrgConfigs) || this.changedSubOrgConfigs.length === 0 || !this.subOrgConfigs) { + return + } + + const changedSubOrgPaths = new Set( + this.changedSubOrgConfigs + .map(subOrg => subOrg && subOrg.path) + .filter(Boolean) + ) + + if (changedSubOrgPaths.size === 0) { + return + } + + if (!this.changedRepoNames) { + this.changedRepoNames = new Set() + } + + Object.entries(this.subOrgConfigs).forEach(([repoName, subOrgConfig]) => { + if (subOrgConfig && subOrgConfig.source && changedSubOrgPaths.has(subOrgConfig.source)) { + this.changedRepoNames.add(repoName) + } + }) + } + + // Identify repos that were previously targeted by suborg config files but + // are no longer targeted after the targeting rules changed. Loads the + // previous version of each changed suborg file from `baseRef`, resolves its + // targeting, and returns repo names present in the old targeting but absent + // from the current `this.subOrgConfigs`. + async getReposRemovedFromSubOrgTargeting (changedSubOrgs, baseRef) { + if (!changedSubOrgs || changedSubOrgs.length === 0 || !baseRef) { + return [] + } + + const removedRepos = [] + + for (const suborg of changedSubOrgs) { + const filePath = suborg.path + if (!filePath) continue + + // Load the previous version of this suborg config file + let previousData + try { + previousData = await this.loadYamlFromRef(filePath, baseRef) + } catch (e) { + this.log.debug(`Could not load previous suborg config from ref ${baseRef}: ${e.message}`) + continue + } + + if (!previousData) continue + + // Resolve repos targeted by the old config + const previouslyTargetedRepos = new Set() + + // 1. suborgrepos: glob patterns (these are repo name patterns) + if (previousData.suborgrepos && Array.isArray(previousData.suborgrepos)) { + for (const repoPattern of previousData.suborgrepos) { + previouslyTargetedRepos.add(repoPattern) + } + } + + // 2. suborgteams: resolve via GitHub API (team membership is live state) + if (previousData.suborgteams && Array.isArray(previousData.suborgteams)) { + try { + const teamPromises = previousData.suborgteams.map(teamslug => + this.getReposForTeam(teamslug) + ) + const teamResults = await Promise.all(teamPromises) + for (const repos of teamResults) { + for (const repo of repos) { + previouslyTargetedRepos.add(repo.name) + } + } + } catch (e) { + this.log.debug(`Error resolving previous suborgteams: ${e.message}`) + } + } + + // 3. suborgproperties: resolve via GitHub API (property values are live state) + if (previousData.suborgproperties && Array.isArray(previousData.suborgproperties)) { + try { + const subOrgRepositories = await this.getSubOrgRepositories(previousData.suborgproperties) + for (const repo of subOrgRepositories) { + previouslyTargetedRepos.add(repo.repository_name) + } + } catch (e) { + this.log.debug(`Error resolving previous suborgproperties: ${e.message}`) + } + } + + // Find repos in previous targeting that are NOT in current targeting + for (const repoName of previouslyTargetedRepos) { + if (!this.getSubOrgConfig(repoName)) { + removedRepos.push(repoName) + } + } + } + + return [...new Set(removedRepos)] + } + + // Load a YAML file from a specific git ref, bypassing the file cache. + // Used to load previous versions of config files for comparison. + async loadYamlFromRef (filePath, ref) { + const repo = { owner: this.repo.owner, repo: env.ADMIN_REPO } + const params = Object.assign(repo, { path: filePath, ref }) + + const response = await this.github.repos.getContent(params) + + if (Array.isArray(response.data)) { + return null + } + + if (typeof response.data.content !== 'string') { + return null + } + + return yaml.load(Buffer.from(response.data.content, 'base64').toString()) || {} } // Create a check in the Admin repo for safe-settings. @@ -156,6 +908,14 @@ class Settings { msg, plugin: this.constructor.name }) + // In NOP mode, also surface the error as an ERROR NopCommand so the NOP + // check run conclusion reflects the failure. Without this, errors caught + // by the syncAll/syncSelectedRepos top-level catch (e.g. invalid + // disable_plugins entries) would go unnoticed by PR reviewers. + if (this.nop) { + const nopcommand = new NopCommand(this.constructor.name, this.repo, null, msg, 'ERROR') + this.appendToResults([nopcommand]) + } } async handleResults () { @@ -168,104 +928,193 @@ class Settings { return } - // remove duplicate rows in this.results + // Remove duplicate rows. The key includes endpoint + action.msg so that: + // - per-operation NopCommands (individual add/update/remove from diffable + // plugins) survive alongside the overall diff-summary NopCommand, and + // - distinct disable_plugins skip messages (each with a unique msg but + // the same empty endpoint) are all retained. this.results = this.results.filter((thing, index, self) => { return index === self.findIndex((t) => { - return t.type === thing.type && t.repo === thing.repo && t.plugin === thing.plugin + return t.type === thing.type && t.repo === thing.repo && t.plugin === thing.plugin && t.endpoint === thing.endpoint && t.action?.msg === thing.action?.msg }) }) + // When a base-branch config is available (NOP / dry-run on a PR), filter + // out results that reflect pre-existing drift rather than changes the PR + // actually introduces. + if (this.baseConfig) { + this.log.debug('Filtering NOP results using base config comparison') + this.results = this.results.filter(res => { + if (!res || res.type === 'ERROR') return true + + if (res.type === 'INFO' && res.action?.msg && res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) { + return true + } + + const isOrgLevel = res.repo && res.repo.endsWith('(org)') + const pluginSection = res.plugin ? res.plugin.toLowerCase() : null + + if (isOrgLevel && pluginSection === 'rulesets') { + // Org-level rulesets: keep only rulesets whose definition changed. + const changedNames = getChangedEntryNames(this.baseConfig.rulesets, this.config.rulesets) + if (changedNames.size === 0) return false + const filtered = filterActionByChangedNames(res.action, changedNames) + if (!filtered) return false + res.action = filtered + return true + } + + if (!isOrgLevel && pluginSection) { + // Keep results for repos whose override/suborg config files changed. + if (this.changedRepoNames && this.changedRepoNames.has(res.repo)) { + return true + } + + // Repo-level rulesets originate from override files, not the global + // config — when no override changed for this repo it is drift. + if (pluginSection === 'rulesets') { + return false + } + + // Other repo-level plugins: drop when the global config section for + // this plugin is unchanged between base and PR. + const baseSection = this.baseConfig[pluginSection] + const prSection = this.config[pluginSection] + if (baseSection !== undefined && prSection !== undefined) { + if (JSON.stringify(baseSection) === JSON.stringify(prSection)) { + return false + } + } + } + + return true + }) + } + let error = false - // Different logic const stats = { - // noOfReposProcessed: new Map(), reposProcessed: {}, changes: {}, - errors: {} - } - /* - Result fields - res.type - res.plugin - res.repo - res.endpoint - res.body - res.action - */ + errors: {}, + // Informational entries (type === 'INFO', all-null diff fields), e.g. + // disable_plugins skip messages. Keyed by repo. + infos: {} + } this.results.forEach(res => { if (res) { stats.reposProcessed[res.repo] = true - // if (res.action.additions === null && res.action.deletions === null && res.action.modifications === null) { - // // No changes - // } else if (res.type === 'ERROR') { error = true if (!stats.errors[res.repo]) { stats.errors[res.repo] = [] } - stats.errors[res.repo].push(res.action) - } else if (!(res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null)) { + const msg = res.action && (res.action.msg || res.action.message) + ? (res.action.msg || res.action.message) + : `${res.action}` + stats.errors[res.repo].push({ msg }) + } else if (res.action?.additions === null && res.action?.deletions === null && res.action?.modifications === null) { + // No diff data — informational message (e.g. disable_plugins skip). + if (res.action?.msg) { + if (!stats.infos[res.repo]) { + stats.infos[res.repo] = [] + } + stats.infos[res.repo].push(`[${res.plugin}] ${res.action.msg}`) + } + } else if (!isEmptyChange(res.action)) { if (!stats.changes[res.plugin]) { stats.changes[res.plugin] = {} } if (!stats.changes[res.plugin][res.repo]) { stats.changes[res.plugin][res.repo] = [] } - stats.changes[res.plugin][res.repo].push(`${res.action}`) + stats.changes[res.plugin][res.repo].push(res.action) } } }) this.log.debug(`Stats ${JSON.stringify(this.results, null, 2)}`) - const table = ` - - - - - - - - - - - - ` + stats.changeSections = buildChangeSections(stats.changes, this.baseConfig, this.config) + stats.reposAffected = affectedRepoCount(stats.changeSections) + stats.changeDetails = stats.changeSections.length > 0 + ? renderChangeSections(stats.changeSections).join('\n\n') + : '' + stats.checkRunDetails = stats.changeDetails.length > 50000 + ? 'Detailed changed-field output is available in the pull request comment.' + : stats.changeDetails const renderedCommentMessage = await eta.renderString(commetMessageTemplate, stats) if (env.CREATE_PR_COMMENT === 'true') { - const summary = ` -#### :robot: Safe-Settings config changes detected: - -${this.results.reduce((x, y) => { - if (!y) { - return x + const pluginSectionList = renderChangeSections(stats.changeSections) + + const errorRepos = Object.keys(stats.errors) + const errorSection = errorRepos.length === 0 + ? '### Errors\n`None`' + : `### Errors\n
\n:warning: Errors — ${errorRepos.length} ${pluralize(errorRepos.length, 'repo', 'repos')} affected\n\n${ + errorRepos.map(repo => + `**${repo}**:\n${stats.errors[repo].map(e => `* ${e.msg}`).join('\n')}` + ).join('\n\n') + }\n\n
` + + // Preserve disable_plugins informational messages in the PR comment. + const infoRepos = Object.keys(stats.infos) + const infoSection = infoRepos.length === 0 + ? '' + : `### Informational messages\n
\n:information_source: Info — ${infoRepos.length} ${pluralize(infoRepos.length, 'repo', 'repos')}\n\n${ + infoRepos.map(repo => + `**${repo}**:\n${stats.infos[repo].map(msg => `* :information_source: ${msg}`).join('\n')}` + ).join('\n\n') + }\n\n
` + + const trailingSections = infoSection ? [errorSection, infoSection] : [errorSection] + const bodySections = stats.changeSections.length === 0 + ? ['_No changes to apply._', ...trailingSections] + : [...pluginSectionList, ...trailingSections] + + const repoCount = Object.keys(stats.reposProcessed).length + const makeHeader = (page, total) => total > 1 + ? `#### :robot: Safe-Settings config changes detected (${page}/${total}):\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n` + : `#### :robot: Safe-Settings config changes detected:\n\n**Repos considered:** ${repoCount}\n**Repos affected:** ${stats.reposAffected}\n\n` + + // Reserve room for the largest possible header so pages never overflow + // the comment limit regardless of the final page count. + const headerOverhead = makeHeader(9999, 9999).length + const bodyLimit = COMMENT_LIMIT - headerOverhead + + const pages = [] + let currentChunks = [] + let currentLength = 0 + const flushPage = () => { + if (currentChunks.length > 0) { + pages.push(currentChunks.join('\n\n')) + currentChunks = [] + currentLength = 0 } - if (y.type === 'ERROR') { - error = true - return `${x} -` - } else if (y.action.additions === null && y.action.deletions === null && y.action.modifications === null) { - return `${x}` - } else { - if (y.action === undefined) { - return `${x}` - } - return `${x} -` + } + for (const section of bodySections) { + const sectionLength = section.length + 2 + if (currentChunks.length > 0 && currentLength + sectionLength > bodyLimit) { + flushPage() } - }, table)} -` + currentChunks.push(section) + currentLength += sectionLength + } + flushPage() + if (pages.length === 0) pages.push('') + const totalPages = pages.length const pullRequest = payload.check_run.check_suite.pull_requests[0] - await this.github.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullRequest.number, - body: summary.length > 55536 ? `${summary.substring(0, 55536)}... (too many changes to report)` : summary - }) + for (let i = 0; i < pages.length; i++) { + const body = `${makeHeader(i + 1, totalPages)}${pages[i]}` + await this.github.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullRequest.number, + body: truncateWithSuffix(body, COMMENT_LIMIT, '... (too many changes to report)') + }) + } } const params = { @@ -277,7 +1126,7 @@ ${this.results.reduce((x, y) => { completed_at: new Date().toISOString(), output: { title: error ? 'Safe-Settings Dry-Run Finished with Error' : 'Safe-Settings Dry-Run Finished with success', - summary: renderedCommentMessage.length > 55536 ? `${renderedCommentMessage.substring(0, 55536)}... (too many changes to report)` : renderedCommentMessage + summary: truncateWithSuffix(renderedCommentMessage, COMMENT_LIMIT, '... (too many changes to report)') } } @@ -290,18 +1139,216 @@ ${this.results.reduce((x, y) => { this.repoConfigs = await this.getRepoConfigs(repo) } + // ──────────────────────────────────────────────────────────────────────── + // disable_plugins helpers + // ──────────────────────────────────────────────────────────────────────── + + // Returns the set of plugin names that are valid `disable_plugins` targets. + static getValidDisablePluginNames () { + return new Set([...Object.keys(Settings.PLUGINS), 'repository', 'archive']) + } + + // Normalize a raw `disable_plugins` list (mixed strings / objects) into + // [{ plugin, target, declaredAt }]. Validates plugin names and target + // values; throws on invalid entries. For declaredAt='repo', `children` + // collapses to `all` (repo has no children). + normalizeDisableEntries (rawList, declaredAt) { + if (rawList === undefined || rawList === null) return [] + if (!Array.isArray(rawList)) { + throw new Error(`disable_plugins at ${declaredAt} must be an array; got ${typeof rawList}`) + } + if (!DISABLE_LEVELS.includes(declaredAt)) { + throw new Error(`Internal: invalid declaredAt '${declaredAt}'`) + } + const validPlugins = Settings.getValidDisablePluginNames() + const normalized = [] + for (const raw of rawList) { + let plugin + let target = 'all' + if (typeof raw === 'string') { + plugin = raw + } else if (raw && typeof raw === 'object') { + plugin = raw.plugin + if (raw.target !== undefined) target = raw.target + } else { + throw new Error(`disable_plugins entry at ${declaredAt} must be a string or {plugin, target}; got ${JSON.stringify(raw)}`) + } + if (!plugin || typeof plugin !== 'string') { + throw new Error(`disable_plugins entry at ${declaredAt} is missing a valid 'plugin' name: ${JSON.stringify(raw)}`) + } + if (!validPlugins.has(plugin)) { + throw new Error(`disable_plugins at ${declaredAt}: unknown plugin '${plugin}'. Valid: ${[...validPlugins].sort().join(', ')}`) + } + if (!DISABLE_TARGETS.has(target)) { + throw new Error(`disable_plugins at ${declaredAt} for plugin '${plugin}': invalid target '${target}'. Valid: ${[...DISABLE_TARGETS].join(', ')}`) + } + if (declaredAt === 'repo' && target === 'children') { + this.log.debug(`disable_plugins: normalizing repo-level target 'children' to 'all' for plugin '${plugin}' (repo has no children)`) + target = 'all' + } + normalized.push({ plugin, target, declaredAt }) + } + return normalized + } + + // Aggregate disable_plugins entries from all four layers (deployment, org, + // suborg matching repoName, repo override for repoName) and expand them via + // the strip matrix into a Map>. If repoName is + // undefined, only deployment + org layers contribute (used by updateOrg). + computeStripMap (repoName) { + const stripMap = new Map() + for (const level of DISABLE_LEVELS) stripMap.set(level, new Set()) + + const layers = [] + // Deployment layer (singleton) + const deploymentRaw = (DeploymentConfig && DeploymentConfig.config && DeploymentConfig.config.disable_plugins) || null + if (deploymentRaw) layers.push(['deployment', deploymentRaw]) + // Org layer + if (this.config && this.config.disable_plugins) { + layers.push(['org', this.config.disable_plugins]) + } + if (repoName !== undefined && repoName !== null) { + const suborg = this.getSubOrgConfig(repoName) + if (suborg && suborg.disable_plugins) { + layers.push(['suborg', suborg.disable_plugins]) + } + const repoOverride = this.getRepoOverrideConfig(repoName) + if (repoOverride && repoOverride.disable_plugins) { + layers.push(['repo', repoOverride.disable_plugins]) + } + } + + for (const [declaredAt, rawList] of layers) { + const entries = this.normalizeDisableEntries(rawList, declaredAt) + for (const { plugin, target } of entries) { + const affected = DISABLE_STRIP_MATRIX[declaredAt][target] || [] + for (const lvl of affected) { + stripMap.get(lvl).add(plugin) + } + } + } + this.log.debug(`disable_plugins stripMap for repo=${repoName || ''}: ${JSON.stringify([...stripMap].map(([k, v]) => [k, [...v]]))}`) + return stripMap + } + + // True if the given plugin appears in ANY layer of the stripMap. Used by + // gates around `repository` / `archive` (and updateOrg's rulesets / + // custom_repository_roles) where the plugin runs per-org or per-repo and + // there's no merge-time pipeline to strip into. + isPluginDisabledAnywhere (stripMap, pluginName) { + if (!stripMap) return false + for (const set of stripMap.values()) { + if (set.has(pluginName)) return true + } + return false + } + + // Returns the declaredAt layer(s) responsible for disabling `pluginName` + // in the given stripMap. Used to build informative NopCommand / log + // messages. Note: stripMap layers are *target* layers, not declaration + // layers — to report the source we re-walk the raw disable_plugins lists. + whoDisabled (pluginName, repoName) { + const sources = [] + const probe = (declaredAt, raw) => { + if (!raw) return + let entries = [] + try { entries = this.normalizeDisableEntries(raw, declaredAt) } catch { return } + for (const e of entries) { + if (e.plugin === pluginName) sources.push(`${declaredAt}(target=${e.target})`) + } + } + probe('deployment', DeploymentConfig && DeploymentConfig.config && DeploymentConfig.config.disable_plugins) + probe('org', this.config && this.config.disable_plugins) + if (repoName !== undefined && repoName !== null) { + const suborg = this.getSubOrgConfig(repoName) + probe('suborg', suborg && suborg.disable_plugins) + const repoOverride = this.getRepoOverrideConfig(repoName) + probe('repo', repoOverride && repoOverride.disable_plugins) + } + return sources + } + + // Apply strips to a `{ deployment, org, suborg, repo }` map of cloned + // configs. Mutates clones in place and returns them. Emits NopCommand + // entries when in nop mode. + applyStrips (stripMap, sources, repoName) { + if (!stripMap) return sources + for (const [level, pluginSet] of stripMap) { + const layer = sources[level] + if (!layer) continue + for (const plugin of pluginSet) { + if (Object.prototype.hasOwnProperty.call(layer, plugin)) { + delete layer[plugin] + this.log.debug(`disable_plugins: stripped '${plugin}' from ${level} layer (repo=${repoName || ''})`) + if (this.nop) { + const declaredBy = this.whoDisabled(plugin, repoName).join(', ') + const nopcommand = new NopCommand('disable_plugins', this.repo, null, `Plugin '${plugin}' stripped from ${level} layer (declared by: ${declaredBy || 'unknown'})`, 'INFO') + this.appendToResults([nopcommand]) + } + } + } + } + return sources + } + + // Emit a NopCommand recording that a per-execution-point plugin + // (rulesets / custom_repository_roles / repository / archive) was skipped + // because it appears in the stripMap. + emitDisableSkip (pluginName, repoName) { + if (!this.nop) return + const declaredBy = this.whoDisabled(pluginName, repoName).join(', ') + const nopcommand = new NopCommand('disable_plugins', this.repo, null, `Plugin '${pluginName}' skipped (declared by: ${declaredBy || 'unknown'})`, 'INFO') + this.appendToResults([nopcommand]) + } + async updateOrg () { + // Org-execution stripMap: no repo context, so only deployment + org + // disable_plugins contribute. + const stripMap = this.computeStripMap() + const additiveSet = this.normalizeAdditivePlugins() + const rulesetsConfig = this.config.rulesets if (rulesetsConfig) { - const RulesetsPlugin = Settings.PLUGINS.rulesets - return new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG).sync().then(res => { - this.appendToResults(res) - }) + if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) { + this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin") + this.emitDisableSkip('rulesets') + } else { + const RulesetsPlugin = Settings.PLUGINS.rulesets + const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG) + rulesetsPlugin.additive = additiveSet.has('rulesets') + await rulesetsPlugin.sync().then(res => { + if (this.nop && Array.isArray(res)) { + res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` }) + } + this.appendToResults(res) + }) + } + } + + const customRepositoryRolesConfig = this.config.custom_repository_roles + if (customRepositoryRolesConfig) { + if (this.isPluginDisabledAnywhere(stripMap, 'custom_repository_roles')) { + this.log.debug("disable_plugins: skipping org-level 'custom_repository_roles' plugin") + this.emitDisableSkip('custom_repository_roles') + } else { + const CustomRepositoryRolesPlugin = Settings.PLUGINS.custom_repository_roles + const customRepositoryRolesPlugin = new CustomRepositoryRolesPlugin(this.nop, this.github, this.repo, customRepositoryRolesConfig, this.log, this.errors) + customRepositoryRolesPlugin.additive = additiveSet.has('custom_repository_roles') + await customRepositoryRolesPlugin.sync().then(res => { + this.appendToResults(res) + }) + } } } async updateRepos (repo) { this.subOrgConfigs = this.subOrgConfigs || await this.getSubOrgConfigs() + // Snapshot the set of suborg `source` paths that match this repo *before* + // we apply any changes. We compare against the post-apply set below to + // decide whether to re-evaluate (and to break stable loops). + const preMatchedSuborgSources = this.reevaluateOnChange + ? this.getAllMatchingSubOrgSources(repo.repo) + : null // Keeping this as is instead of doing an object assign as that would cause `Cannot read properties of undefined (reading 'startsWith')` error // Copilot code review would recoommend using object assign but that would cause the error let repoConfig = this.config.repository @@ -334,14 +1381,34 @@ ${this.results.reduce((x, y) => { repoConfig = this.mergeDeep.mergeDeep({}, repoConfig, overrideRepoConfig) } if (repoConfig) { + // Per-repo disable_plugins stripMap (used to gate repository + archive + // plugins, which run per-repo outside the childPluginsList pipeline). + const repoStripMap = this.computeStripMap(repo.repo) + const repositoryDisabled = this.isPluginDisabledAnywhere(repoStripMap, 'repository') + const archiveDisabled = this.isPluginDisabledAnywhere(repoStripMap, 'archive') + + // Track actual change signals from the plugins, used by the suborg + // re-evaluation logic below to avoid an unnecessary live API round-trip + // when nothing relevant actually changed. + const changeSignals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false } try { this.log.debug(`found a matching repoconfig for this repo ${JSON.stringify(repoConfig)}`) const childPlugins = this.childPluginsList(repo) const RepoPlugin = Settings.PLUGINS.repository - const archivePlugin = new Archive(this.nop, this.github, repo, repoConfig, this.log) - const { shouldArchive, shouldUnarchive } = await archivePlugin.getState() + let archivePlugin = null + let shouldArchive = false + let shouldUnarchive = false + if (archiveDisabled) { + this.log.debug(`disable_plugins: skipping 'archive' plugin for ${repo.repo}`) + this.emitDisableSkip('archive', repo.repo) + } else { + archivePlugin = new Archive(this.nop, this.github, repo, repoConfig, this.log) + const state = await archivePlugin.getState() + shouldArchive = state.shouldArchive + shouldUnarchive = state.shouldUnarchive + } if (shouldUnarchive) { this.log.debug(`Unarchiving repo ${repo.repo}`) @@ -349,16 +1416,35 @@ ${this.results.reduce((x, y) => { this.appendToResults(unArchiveResults) } - const repoResults = await new RepoPlugin(this.nop, this.github, repo, repoConfig, this.installation_id, this.log, this.errors).sync() - this.appendToResults(repoResults) + if (repositoryDisabled) { + this.log.debug(`disable_plugins: skipping 'repository' plugin for ${repo.repo}`) + this.emitDisableSkip('repository', repo.repo) + } else { + const repoPluginInstance = new RepoPlugin(this.nop, this.github, repo, repoConfig, this.installation_id, this.log, this.errors) + const repoResults = await repoPluginInstance.sync() + this.appendToResults(repoResults) + if (repoPluginInstance.renamed) changeSignals.renamed = true + if (repoPluginInstance.created) changeSignals.created = true + } + const additiveSet = this.normalizeAdditivePlugins() + const childPluginInstances = childPlugins.map(([Plugin, config, section]) => { + const instance = new Plugin(this.nop, this.github, repo, config, this.log, this.errors) + instance.additive = additiveSet.has(section) + return [Plugin, instance] + }) const childResults = await Promise.all( - childPlugins.map(([Plugin, config]) => { - return new Plugin(this.nop, this.github, repo, config, this.log, this.errors).sync() - }) + childPluginInstances.map(([, instance]) => instance.sync()) ) this.appendToResults(childResults) + // Collect change signals from relevant child plugins. + for (const [Plugin, instance] of childPluginInstances) { + if (!instance.hasChanges) continue + if (Plugin === Settings.PLUGINS.teams) changeSignals.teamsChanged = true + if (Plugin === Settings.PLUGINS.custom_properties) changeSignals.propertiesChanged = true + } + if (shouldArchive) { this.log.debug(`Archiving repo ${repo.repo}`) const archiveResults = await archivePlugin.sync() @@ -374,11 +1460,23 @@ ${this.results.reduce((x, y) => { throw e } } + + // Suborg re-evaluation: if a repo-yml change actually applied teams or + // custom_properties (or this repo was just renamed/created), the repo + // may newly match or stop matching a suborg config + // (suborgteams/suborgproperties/suborgrepos). Refresh the suborg cache, + // compare matched-source sets; if the set changed, re-run updateRepos + // once for this repo. Bounded by + // MAX_REEVALUATION_DEPTH and a stable-set check to prevent loops. + await this.maybeReevaluateSuborg(repo, repoConfig, preMatchedSuborgSources, changeSignals) } else { this.log.debug(`Didnt find any a matching repoconfig for this repo ${JSON.stringify(repo)} in ${JSON.stringify(this.repoConfigs)}`) const childPlugins = this.childPluginsList(repo) - return Promise.all(childPlugins.map(([Plugin, config]) => { - return new Plugin(this.nop, this.github, repo, config, this.log, this.errors).sync().then(res => { + const additiveSet = this.normalizeAdditivePlugins() + return Promise.all(childPlugins.map(([Plugin, config, section]) => { + const instance = new Plugin(this.nop, this.github, repo, config, this.log, this.errors) + instance.additive = additiveSet.has(section) + return instance.sync().then(res => { this.appendToResults(res) }) })) @@ -393,6 +1491,18 @@ ${this.results.reduce((x, y) => { }) } + async updateChangedRepoConfigs (changedRepos = []) { + if (!Array.isArray(changedRepos) || changedRepos.length === 0) return + + const seen = new Set() + for (const repo of changedRepos) { + if (!repo || !repo.repo || seen.has(repo.repo)) continue + seen.add(repo.repo) + if (this.processedRepoNames.has(repo.repo)) continue + await this.checkAndProcessRepo(repo.owner || this.repo.owner, repo.repo) + } + } + getSubOrgConfig (repoName) { if (this.subOrgConfigs) { for (const pattern of Object.keys(this.subOrgConfigs)) { @@ -405,19 +1515,192 @@ ${this.results.reduce((x, y) => { return undefined } + // Read-only helper used for suborg re-evaluation stability checks. + // Returns the set of suborg `source` paths (i.e. the suborg config file path) + // that match the given repo name. Apply-time behavior is unchanged: + // `getSubOrgConfig` still returns the first match and + // `storeSubOrgConfigIfNoConflicts` still forbids multi-suborg overlap at + // config-load time -- so this set normally contains 0 or 1 entries. We + // expose it as a Set so callers can detect the transition from {} -> {pathA} + // when a repo newly matches a suborg after teams/properties are applied. + getAllMatchingSubOrgSources (repoName) { + const sources = new Set() + if (!this.subOrgConfigs) { + return sources + } + for (const pattern of Object.keys(this.subOrgConfigs)) { + const glob = new Glob(pattern) + if (glob.test(repoName)) { + const source = this.subOrgConfigs[pattern]?.source + if (source) { + sources.add(source) + } + } + } + return sources + } + + // Force a refresh of the cached suborg configs. Used by the re-eval loop + // because suborgteams / suborgproperties resolution calls live GitHub APIs + // and may now match the repo after teams/properties were applied in the + // first pass. + async reloadSubOrgConfigs () { + this.subOrgConfigs = await this.getSubOrgConfigs() + } + + // Decide whether applying this repo's config actually changed state that + // could affect suborg matching. If no relevant change happened, skip the + // re-eval API roundtrip entirely. + // + // Preferred path: use plugin-emitted change signals from the just-completed + // sync (teams plugin actually added/removed/updated, custom_properties + // plugin changed values, repository plugin renamed/created). These come + // from the Diffable base class (`plugin.hasChanges`) and the Repository + // plugin (`renamed`, `created`). + // + // Fallback (changeSignals omitted, e.g. unit tests calling the helper in + // isolation): inspect the per-repo yml top-level shape for teams / + // custom_properties / rename indicators. + shouldConsiderReevaluation (repo, repoConfig, changeSignals) { + if (changeSignals) { + return !!( + changeSignals.teamsChanged || + changeSignals.propertiesChanged || + changeSignals.renamed || + changeSignals.created + ) + } + const repoYml = this.repoConfigs && ( + this.repoConfigs[`${repo.repo}.yml`] || this.repoConfigs[`${repo.repo}.yaml`] + ) + if (repoYml) { + if (Array.isArray(repoYml.teams) && repoYml.teams.length > 0) return true + if (Array.isArray(repoYml.custom_properties) && repoYml.custom_properties.length > 0) return true + } + if (repo && repo.oldname && repo.oldname !== repo.repo) return true + if (repoConfig && repoConfig.oldname && repoConfig.oldname !== repoConfig.name) return true + return false + } + + // After applying changes to a repo, decide whether to re-run updateRepos + // because the applied changes may have changed whether the repo matches a + // suborg config. Loop prevention has two layers: + // 1. Hard cap: MAX_REEVALUATION_DEPTH (=1) re-evaluation passes per repo. + // 2. Stability check: stop if the set of matched suborg sources did not + // grow (no new suborg source appeared since the last pass). + async maybeReevaluateSuborg (repo, repoConfig, preMatchedSuborgSources, changeSignals) { + if (!this.reevaluateOnChange) return + if (!preMatchedSuborgSources) return + if (!this.shouldConsiderReevaluation(repo, repoConfig, changeSignals)) { + this.log.debug(`Suborg re-eval: skipping for ${repo.repo} (no relevant changes from teams/custom_properties/repository plugins)`) + return + } + + const depth = this.reevaluationDepth.get(repo.repo) || 0 + if (depth >= MAX_REEVALUATION_DEPTH) { + this.log.warn(`Suborg re-eval: max depth (${MAX_REEVALUATION_DEPTH}) reached for ${repo.repo}; stopping. Any further suborg matches will be picked up on the next sync.`) + return + } + + // Refresh suborg config cache; suborgteams/suborgproperties resolution + // hits live GitHub APIs and may now match this repo. + await this.reloadSubOrgConfigs() + + const newMatched = this.getAllMatchingSubOrgSources(repo.repo) + + // Stability check: if the source set did not change, we're done. A change + // can be either a newly matched suborg or a removed match after teams or + // custom_properties changed. + let hasChanged = preMatchedSuborgSources.size !== newMatched.size + if (!hasChanged) { + for (const source of newMatched) { + if (!preMatchedSuborgSources.has(source)) { + hasChanged = true + break + } + } + } + if (!hasChanged) { + this.log.debug(`Suborg re-eval: stable for ${repo.repo} (matched sources: ${JSON.stringify(Array.from(newMatched))}); stopping.`) + return + } + + this.reevaluatedRepos.set(repo.repo, new Set([...preMatchedSuborgSources, ...newMatched])) + this.reevaluationDepth.set(repo.repo, depth + 1) + this.log.debug(`Suborg re-eval: suborg sources changed for ${repo.repo} after apply; re-running updateRepos (depth=${depth + 1}).`) + + // Reload repo-level configs for this repo so the next pass picks up any + // state changes; then recurse. Depth cap above prevents infinite loops. + this.repoConfigs = await this.getRepoConfigs(repo) + await this.updateRepos(repo) + } + // Remove Org specific configs from the repo config returnRepoSpecificConfigs (config) { const newConfig = Object.assign({}, config) // clone delete newConfig.rulesets + delete newConfig.custom_repository_roles + delete newConfig.disable_plugins + delete newConfig.additive_plugins return newConfig } + // Shallow-clone a config object and strip metadata keys (`disable_plugins`, + // `additive_plugins`) that are policy controls, not plugin section config. + cloneAndStripDisableMeta (config) { + if (!config) return {} + const clone = Object.assign({}, config) + delete clone.disable_plugins + delete clone.additive_plugins + return clone + } + + // Parse and validate the `additive_plugins` list from the org-level config. + // Returns a Set of plugin names that should run in additive mode + // (remove() calls suppressed). Logs an error for unknown or non-Diffable + // plugin names and excludes them from the returned set. + normalizeAdditivePlugins () { + const raw = (this.config && this.config.additive_plugins) || [] + if (!Array.isArray(raw)) { + this.logError(`additive_plugins must be an array; got ${typeof raw}`) + return new Set() + } + const validPlugins = Settings.ADDITIVE_PLUGINS + const result = new Set() + for (const name of raw) { + if (typeof name !== 'string') { + this.logError(`additive_plugins: each entry must be a string plugin name; got ${JSON.stringify(name)}`) + continue + } + if (!validPlugins.has(name)) { + this.logError(`additive_plugins: unknown or non-Diffable plugin '${name}'. Valid: ${[...validPlugins].sort().join(', ')}`) + continue + } + result.add(name) + } + return result + } + childPluginsList (repo) { const repoName = repo.repo const subOrgOverrideConfig = this.getSubOrgConfig(repoName) this.log.debug(`suborg config for ${repoName} is ${JSON.stringify(subOrgOverrideConfig)}`) const repoOverrideConfig = this.getRepoOverrideConfig(repoName) - const overrideConfig = this.mergeDeep.mergeDeep({}, this.returnRepoSpecificConfigs(this.config), subOrgOverrideConfig, repoOverrideConfig) + + // Build clones of each layer and apply disable_plugins strips before the + // existing mergeDeep pipeline runs. The deployment layer's strips affect + // the OTHER three layers (per the matrix); the deployment config itself + // is not merged into per-repo plugin config today. + const stripMap = this.computeStripMap(repoName) + const sources = { + deployment: this.cloneAndStripDisableMeta((DeploymentConfig && DeploymentConfig.config) || {}), + org: this.returnRepoSpecificConfigs(this.config), + suborg: this.cloneAndStripDisableMeta(subOrgOverrideConfig), + repo: this.cloneAndStripDisableMeta(repoOverrideConfig) + } + this.applyStrips(stripMap, sources, repoName) + + const overrideConfig = this.mergeDeep.mergeDeep({}, sources.org, sources.suborg, sources.repo) this.log.debug(`consolidated config is ${JSON.stringify(overrideConfig)}`) @@ -437,7 +1720,9 @@ ${this.results.reduce((x, y) => { if (section in Settings.PLUGINS) { this.log.debug(`Found section ${section} in the config. Creating plugin...`) const Plugin = Settings.PLUGINS[section] - childPlugins.push([Plugin, config]) + // Include sectionName as 3rd element so callers can thread the + // additive_plugins flag without re-deriving the plugin key. + childPlugins.push([Plugin, config, section]) } } } @@ -506,17 +1791,21 @@ ${this.results.reduce((x, y) => { log.debug('Fetching repositories') return github.paginate('GET /installation/repositories').then(repositories => { return Promise.all(repositories.map(repository => { - if (this.isRestricted(repository.name)) { - return null - } - const { owner, name } = repository - return this.updateRepos({ owner: owner.login, repo: name }) + return this.checkAndProcessRepo(owner.login, name) }) ) }) } + async checkAndProcessRepo (owner, name) { + this.processedRepoNames.add(name) + if (this.isRestricted(name)) { + return null + } + return this.updateRepos({ owner, repo: name }) + } + /** * Loads a file from GitHub * @@ -952,18 +2241,28 @@ ${this.results.reduce((x, y) => { } } -function prettify (obj) { - if (obj === null || obj === undefined) { - return '' - } - return JSON.stringify(obj, null, 2).replaceAll('\n', '
').replaceAll(' ', ' ') -} - Settings.FILE_NAME = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH) Settings.FILE_PATH = path.posix.join(CONFIG_PATH, env.SETTINGS_FILE_PATH) Settings.SUB_ORG_PATTERN = new Glob(`${CONFIG_PATH}/suborgs/*.yml`) Settings.REPO_PATTERN = new Glob(`${CONFIG_PATH}/repos/*.yml`) +// Plugin names that support additive_plugins (all extend Diffable and have +// a meaningful remove() concept). Non-Diffable plugins (repository, archive, +// branches, validator) are intentionally excluded — listing them in +// additive_plugins will produce a validation error. +Settings.ADDITIVE_PLUGINS = new Set([ + 'labels', + 'collaborators', + 'teams', + 'milestones', + 'autolinks', + 'environments', + 'custom_properties', + 'variables', + 'rulesets', + 'custom_repository_roles' +]) + Settings.PLUGINS = { repository: require('./plugins/repository'), labels: require('./plugins/labels'), @@ -976,7 +2275,12 @@ Settings.PLUGINS = { rulesets: require('./plugins/rulesets'), environments: require('./plugins/environments'), custom_properties: require('./plugins/custom_properties.js'), + custom_repository_roles: require('./plugins/custom_repository_roles'), variables: require('./plugins/variables') } module.exports = Settings +module.exports.isEmptyChange = isEmptyChange +module.exports.isDeepEmpty = isDeepEmpty +module.exports.getChangedEntryNames = getChangedEntryNames +module.exports.filterActionByChangedNames = filterActionByChangedNames diff --git a/lib/settingsGenerator.js b/lib/settingsGenerator.js new file mode 100644 index 000000000..64e30d663 --- /dev/null +++ b/lib/settingsGenerator.js @@ -0,0 +1,498 @@ +/* eslint-disable camelcase */ +const yaml = require('js-yaml') +const Settings = require('./settings') +const MergeDeep = require('./mergeDeep') +const env = require('./env') + +/** + * SettingsGenerator + * + * The reverse of safe-settings: read the *current* configuration of a repo / + * org / collection-of-repos from the GitHub API and emit safe-settings YAML + * (`repos/.yml`, `settings.yml`, `suborgs/.yml`). + * + * The heavy lifting (knowing which API to call to read current state) already + * lives in each plugin's `find()` method, so wherever possible we instantiate + * the existing plugin in nop mode with empty entries and reuse its `find()`. + * The raw API shape is then reduced to the configurable subset that the + * safe-settings schema understands. + */ + +// Keys that are pure API noise and should never appear in generated config. +const NOISE_KEYS = new Set([ + 'id', 'node_id', 'url', 'html_url', 'repository_url', 'labels_url', 'events_url', + 'created_at', 'updated_at', 'pushed_at', 'creator', '_links', 'current_user_can_bypass' +]) + +function makeLogger () { + const noop = () => {} + const logger = { debug: noop, info: noop, warn: noop, error: noop, trace: noop } + logger.child = () => logger + return logger +} + +/** + * Recursively strip API-only noise keys from an arbitrary value. + * Used for sections (rulesets, environments) whose API shape is large and + * not worth hand-mapping field by field. + */ +function stripNoise (value) { + if (Array.isArray(value)) { + return value.map(stripNoise) + } + if (value && typeof value === 'object') { + const out = {} + for (const [k, v] of Object.entries(value)) { + if (NOISE_KEYS.has(k)) continue + if (v === null || v === undefined) continue + out[k] = stripNoise(v) + } + return out + } + return value +} + +/** Remove sections whose value is empty (undefined, [], {} ). */ +function pruneEmpty (config) { + const out = {} + for (const [section, value] of Object.entries(config)) { + if (value === undefined || value === null) continue + if (Array.isArray(value) && value.length === 0) continue + if (!Array.isArray(value) && typeof value === 'object' && Object.keys(value).length === 0) continue + out[section] = value + } + return out +} + +class SettingsGenerator { + /** + * @param {object} github An authenticated octokit instance. + * @param {string} owner The org / owner login. + * @param {object} [opts] + * @param {object} [opts.log] Logger; defaults to a silent logger. + */ + constructor (github, owner, opts = {}) { + this.github = github + this.owner = owner + this.log = opts.log || makeLogger() + this.errors = [] + } + + /** + * Instantiate a Diffable plugin in nop mode with empty entries and return + * its `find()` result (the current state read from GitHub). + * + * @param {string} section Plugin/section name (key of Settings.PLUGINS). + * @param {object} repo { owner, repo } + * @param {string} [scope] Optional scope passed to plugins that accept it + * (rulesets uses 'org' | 'repo'). + */ + async findExisting (section, repo, scope) { + const Plugin = Settings.PLUGINS[section] + if (!Plugin) throw new Error(`Unknown plugin section: ${section}`) + const instance = new Plugin(true, this.github, repo, [], this.log, this.errors, scope) + return instance.find() + } + + // --- Section extractors ------------------------------------------------- + + async repository (repo) { + const { data } = await this.github.repos.get(repo) + const fields = [ + 'name', 'description', 'homepage', 'private', 'visibility', + 'has_issues', 'has_projects', 'has_wiki', 'has_downloads', 'is_template', + 'default_branch', 'allow_squash_merge', 'allow_merge_commit', + 'allow_rebase_merge', 'allow_auto_merge', 'delete_branch_on_merge', + 'allow_update_branch', 'squash_merge_commit_title', 'squash_merge_commit_message', + 'merge_commit_title', 'merge_commit_message', 'web_commit_signoff_required', + 'archived' + ] + const out = {} + for (const f of fields) { + if (data[f] !== undefined && data[f] !== null) out[f] = data[f] + } + if (Array.isArray(data.topics) && data.topics.length > 0) out.topics = data.topics + return out + } + + async labels (repo) { + const existing = await this.findExisting('labels', repo) + return (existing || []).map(({ name, color, description }) => ({ + name, + color: color ? String(color) : undefined, + description: description || undefined + })) + } + + async collaborators (repo) { + const existing = await this.findExisting('collaborators', repo) + return (existing || []) + .filter(c => c && c.username) + .map(({ username, permission }) => ({ username, permission })) + } + + async teams (repo) { + const existing = await this.findExisting('teams', repo) + return (existing || []).map(t => ({ + name: t.slug || t.name, + permission: t.permission + })) + } + + async milestones (repo) { + const existing = await this.findExisting('milestones', repo) + return (existing || []).map(({ title, description, state }) => ({ + title, + description: description || undefined, + state: state || undefined + })) + } + + async autolinks (repo) { + const existing = await this.findExisting('autolinks', repo) + return (existing || []).map(({ key_prefix, url_template, is_alphanumeric }) => ({ + key_prefix, + url_template, + is_alphanumeric + })) + } + + async custom_properties (repo) { + const existing = await this.findExisting('custom_properties', repo) + return (existing || []).filter(p => p && p.value !== null && p.value !== undefined) + } + + async variables (repo) { + const existing = await this.findExisting('variables', repo) + return (existing || []).map(({ name, value }) => ({ name, value })) + } + + async environments (repo) { + const existing = await this.findExisting('environments', repo) + return stripNoise(existing || []) + } + + async rulesets (repo, scope = 'repo') { + const existing = await this.findExisting('rulesets', repo, scope) + return (existing || []).map(rs => { + const { source, source_type, ...rest } = stripNoise(rs) + return rest + }) + } + + async custom_repository_roles (repo) { + const existing = await this.findExisting('custom_repository_roles', repo) + return (existing || []).map(({ id, ...rest }) => rest) + } + + async branches (repo) { + let branchList + try { + branchList = await this.github.paginate(this.github.repos.listBranches, { + owner: repo.owner, + repo: repo.repo, + protected: true, + per_page: 100 + }) + } catch (e) { + this.log.debug(`Could not list protected branches for ${repo.repo}: ${e.message}`) + return [] + } + + const result = [] + for (const b of branchList || []) { + try { + const { data } = await this.github.repos.getBranchProtection({ + owner: repo.owner, + repo: repo.repo, + branch: b.name + }) + result.push({ name: b.name, protection: this.reformatBranchProtection(data) }) + } catch (e) { + this.log.debug(`Could not read branch protection for ${repo.repo}#${b.name}: ${e.message}`) + } + } + return result + } + + /** + * Convert the GitHub branch-protection API response into the flatter shape + * used by safe-settings config (boolean toggles instead of `{ enabled }`). + * Mirrors Branches.reformatAndReturnBranchProtection. + */ + reformatBranchProtection (protection) { + if (!protection) return protection + const p = stripNoise(protection) + const flatten = key => { + if (p[key] && typeof p[key] === 'object' && 'enabled' in p[key]) { + p[key] = p[key].enabled + } + } + flatten('required_conversation_resolution') + flatten('allow_deletions') + flatten('required_linear_history') + flatten('enforce_admins') + flatten('required_signatures') + flatten('allow_force_pushes') + flatten('block_creations') + flatten('lock_branch') + return p + } + + // --- Scope builders ----------------------------------------------------- + + /** + * Build the full repo-level config object for a single repository. + * @param {string} repoName + * @returns {Promise} pruned config (empty sections removed) + */ + async buildRepoConfig (repoName) { + const repo = { owner: this.owner, repo: repoName } + const sections = [ + 'repository', 'labels', 'collaborators', 'teams', 'milestones', + 'branches', 'autolinks', 'custom_properties', 'variables', + 'environments' + ] + const config = {} + for (const section of sections) { + try { + config[section] = await this[section](repo) + } catch (e) { + this.log.warn(`Failed to extract ${section} for ${repoName}: ${e.message}`) + } + } + try { + config.rulesets = await this.rulesets(repo, 'repo') + } catch (e) { + this.log.warn(`Failed to extract rulesets for ${repoName}: ${e.message}`) + } + return pruneEmpty(config) + } + + /** + * Build the org-level (settings.yml) config. At org scope we can only read + * org-level rulesets and custom repository roles. + * @returns {Promise} + */ + async buildOrgConfig () { + const repo = { owner: this.owner, repo: env.ADMIN_REPO } + const config = {} + try { + config.rulesets = await this.rulesets(repo, 'org') + } catch (e) { + this.log.warn(`Failed to extract org rulesets: ${e.message}`) + } + try { + config.custom_repository_roles = await this.custom_repository_roles(repo) + } catch (e) { + this.log.warn(`Failed to extract custom repository roles: ${e.message}`) + } + return pruneEmpty(config) + } + + /** + * Build a suborg config for all repos that carry a custom property value. + * Settings common to ALL matching repos are kept (intersection). + * @param {string} propertyName + * @param {string|boolean} propertyValue + * @returns {Promise} + */ + async buildSubOrgConfig (propertyName, propertyValue) { + const repos = await this.findReposByProperty(propertyName, propertyValue) + if (repos.length === 0) { + return { suborgproperties: [{ [propertyName]: propertyValue }] } + } + + const configs = [] + for (const repoName of repos) { + configs.push(await this.buildRepoConfig(repoName)) + } + + const common = intersectConfigs(configs) + return Object.assign( + { suborgproperties: [{ [propertyName]: propertyValue }] }, + pruneEmpty(common) + ) + } + + /** + * High level entry point. Resolve the target file path and config content + * for a given source descriptor. + * + * @param {object} source + * @param {'repo'|'org'|'custom-property'} source.sourceType + * @param {string} source.sourceValue For 'repo' the repo name; for 'org' the + * org login; for 'custom-property' a `name=value` pair (or just the value + * if `propertyName` is supplied separately). + * @param {string} [source.propertyName] Custom property name (alternative to + * encoding it in sourceValue). + * @returns {Promise<{ filePath: string, config: object, yaml: string }>} + */ + async generate ({ sourceType, sourceValue, propertyName } = {}) { + let config + let filePath + const base = env.CONFIG_PATH + + switch (sourceType) { + case 'repo': { + config = await this.buildRepoConfig(sourceValue) + filePath = `${base}/repos/${sourceValue}.yml` + break + } + case 'org': { + config = await this.buildOrgConfig() + filePath = `${base}/${env.SETTINGS_FILE_PATH}` + break + } + case 'custom-property': + case 'custom-property-name': { + const { name, value } = parsePropertyValue(sourceValue, propertyName) + config = await this.buildSubOrgConfig(name, value) + filePath = `${base}/suborgs/${name}_${value}.yml` + break + } + default: + throw new Error(`Unsupported source type: ${sourceType}`) + } + + return { filePath, config, yaml: toYaml(config) } + } + + /** + * Discover repository names that have a given custom property value. + * Mirrors Settings.getRepositoriesByProperty. + * @returns {Promise} + */ + async findReposByProperty (propertyName, propertyValue) { + const query = `props.${propertyName}:${propertyValue}` + const encodedQuery = encodeURIComponent(query) + const options = this.github.request.endpoint( + `/orgs/${this.owner}/properties/values?repository_query=${encodedQuery}` + ) + const results = await this.github.paginate(options) + return (results || []) + .map(r => r.repository_name) + .filter(Boolean) + } +} + +// --- Intersection helpers ------------------------------------------------- +const NAME_FIELDS = (MergeDeep.NAME_FIELDS || []) + .concat(['title']) + +function identityOf (item) { + if (!item || typeof item !== 'object') return undefined + const prop = NAME_FIELDS.find(p => Object.prototype.hasOwnProperty.call(item, p)) + return prop ? `${prop}:${item[prop]}` : undefined +} + +function deepEqual (a, b) { + if (a === b) return true + if (typeof a !== typeof b) return false + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + return a.every((x, i) => deepEqual(x, b[i])) + } + if (a && b && typeof a === 'object') { + const ak = Object.keys(a) + const bk = Object.keys(b) + if (ak.length !== bk.length) return false + return ak.every(k => deepEqual(a[k], b[k])) + } + return false +} + +/** + * Reduce a list of config objects to the parts that are identical across ALL + * of them. + * - scalars: kept only if equal everywhere + * - arrays: items kept only if an item with the same identity (NAME_FIELDS) + * AND deep-equal value is present in every config + * - objects: recursively intersected + * @param {object[]} configs + * @returns {object} + */ +function intersectConfigs (configs) { + if (!configs || configs.length === 0) return {} + if (configs.length === 1) return configs[0] + + const result = {} + // Only consider sections present in every config. + const commonSections = Object.keys(configs[0]).filter(section => + configs.every(c => Object.prototype.hasOwnProperty.call(c, section)) + ) + + for (const section of commonSections) { + const values = configs.map(c => c[section]) + result[section] = intersectValues(values) + } + return result +} + +function intersectValues (values) { + const [first] = values + + if (Array.isArray(first)) { + if (!values.every(Array.isArray)) return undefined + const kept = [] + for (const item of first) { + const id = identityOf(item) + const presentEverywhere = values.every(arr => + arr.some(other => (id !== undefined + ? identityOf(other) === id && deepEqual(other, item) + : deepEqual(other, item))) + ) + if (presentEverywhere) kept.push(item) + } + return kept + } + + if (first && typeof first === 'object') { + if (!values.every(v => v && typeof v === 'object' && !Array.isArray(v))) return undefined + const out = {} + const commonKeys = Object.keys(first).filter(k => + values.every(v => Object.prototype.hasOwnProperty.call(v, k)) + ) + for (const k of commonKeys) { + const intersected = intersectValues(values.map(v => v[k])) + if (intersected !== undefined) out[k] = intersected + } + return out + } + + // scalar + return values.every(v => deepEqual(v, first)) ? first : undefined +} + +/** Serialize a config object to YAML. */ +function toYaml (config) { + return yaml.dump(config, { lineWidth: -1, noRefs: true }) +} + +/** + * Parse a custom-property source value. Accepts either a `name=value` pair, a + * `name:value` pair, or just a value when `propertyName` is supplied. + * @returns {{ name: string, value: string }} + */ +function parsePropertyValue (sourceValue, propertyName) { + if (propertyName) { + return { name: propertyName, value: sourceValue } + } + const match = /^([^=:]+)[=:](.+)$/.exec(String(sourceValue || '')) + if (!match) { + throw new Error( + `custom-property source requires a "name=value" pair (got "${sourceValue}")` + ) + } + return { name: match[1].trim(), value: match[2].trim() } +} + +module.exports = SettingsGenerator +module.exports.SettingsGenerator = SettingsGenerator +module.exports.intersectConfigs = intersectConfigs +module.exports.intersectValues = intersectValues +module.exports.deepEqual = deepEqual +module.exports.stripNoise = stripNoise +module.exports.pruneEmpty = pruneEmpty +module.exports.toYaml = toYaml +module.exports.parsePropertyValue = parsePropertyValue diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 000000000..e69de29bb diff --git a/package-lock.json b/package-lock.json index aba003965..ca4330887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,22 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^12.0.2", + "@octokit/auth-app": "^8.0.2", "@probot/adapter-aws-lambda-serverless": "^4.0.3", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", "deepmerge": "^4.3.1", "eta": "^3.5.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "minimatch": "^10.0.1", + "minimatch": "^10.0.3", + "next": "^15.5.2", "node-cron": "^3.0.2", "octokit": "^5.0.2", "probot": "^13.4.4", "proxy-from-env": "^1.1.0", + "swr": "^2.4.1", "undici": "^7.7.0" }, "devDependencies": { @@ -706,6 +712,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -887,12 +903,451 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", "license": "MIT" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1701,21 +2156,155 @@ "node": ">=18" } }, - "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, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } + "node_modules/@next/env": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", + "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "license": "MIT" }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", + "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", + "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", + "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", + "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", + "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", + "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", + "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", + "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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, + "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, @@ -1753,68 +2342,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/app/node_modules/@octokit/auth-app": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.0.1.tgz", - "integrity": "sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg==", - "dependencies": { - "@octokit/auth-oauth-app": "^9.0.1", - "@octokit/auth-oauth-user": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/request-error": "^7.0.0", - "@octokit/types": "^14.0.0", - "toad-cache": "^3.7.0", - "universal-github-app-jwt": "^2.2.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/auth-oauth-app": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.1.tgz", - "integrity": "sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g==", - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.1", - "@octokit/auth-oauth-user": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/auth-oauth-device": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.1.tgz", - "integrity": "sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw==", - "dependencies": { - "@octokit/oauth-methods": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/auth-oauth-user": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.0.tgz", - "integrity": "sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A==", - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.1", - "@octokit/oauth-methods": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@octokit/app/node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -1877,28 +2404,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/app/node_modules/@octokit/oauth-authorization-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", - "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/oauth-methods": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.0.tgz", - "integrity": "sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA==", - "dependencies": { - "@octokit/oauth-authorization-url": "^8.0.0", - "@octokit/request": "^10.0.2", - "@octokit/request-error": "^7.0.0", - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@octokit/app/node_modules/@octokit/openapi-types": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", @@ -1978,156 +2483,325 @@ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==" }, - "node_modules/@octokit/app/node_modules/universal-github-app-jwt": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==" - }, "node_modules/@octokit/app/node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" }, "node_modules/@octokit/auth-app": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.1.3.tgz", - "integrity": "sha512-dcaiteA6Y/beAlDLZOPNReN3FGHu+pARD6OHfh3T9f3EO09++ec+5wt3KtGGSSs2Mp5tI8fQwdMOEnrzBLfgUA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.0.2.tgz", + "integrity": "sha512-dLTmmA9gUlqiAJZgozfOsZFfpN/OldH3xweb7lqSnngax5Rs+PfO5dDlokaBfc41H1xOtsLYV5QqR0DkBAtPmw==", "license": "MIT", "dependencies": { - "@octokit/auth-oauth-app": "^7.1.0", - "@octokit/auth-oauth-user": "^4.1.0", - "@octokit/request": "^8.3.1", - "@octokit/request-error": "^5.1.0", - "@octokit/types": "^13.1.0", - "deprecation": "^2.3.1", - "lru-cache": "npm:@wolfy1339/lru-cache@^11.0.2-patch.1", - "universal-github-app-jwt": "^1.1.2", - "universal-user-agent": "^6.0.0" + "@octokit/auth-oauth-app": "^9.0.1", + "@octokit/auth-oauth-user": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-app/node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, - "node_modules/@octokit/auth-app/node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "node_modules/@octokit/auth-app/node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, - "node_modules/@octokit/auth-app/node_modules/lru-cache": { - "name": "@wolfy1339/lru-cache", - "version": "11.0.2-patch.1", - "resolved": "https://registry.npmjs.org/@wolfy1339/lru-cache/-/lru-cache-11.0.2-patch.1.tgz", - "integrity": "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==", - "license": "ISC", + "node_modules/@octokit/auth-app/node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, "engines": { - "node": "18 >=18.20 || 20 || >=22" + "node": ">= 20" } }, + "node_modules/@octokit/auth-app/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/auth-app/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/@octokit/auth-oauth-app": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-7.1.0.tgz", - "integrity": "sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.1.tgz", + "integrity": "sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g==", "license": "MIT", "dependencies": { - "@octokit/auth-oauth-device": "^6.1.0", - "@octokit/auth-oauth-user": "^4.1.0", - "@octokit/request": "^8.3.1", - "@octokit/types": "^13.0.0", - "@types/btoa-lite": "^1.0.0", - "btoa-lite": "^1.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/auth-oauth-device": "^8.0.1", + "@octokit/auth-oauth-user": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, + "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^25.1.0" } }, + "node_modules/@octokit/auth-oauth-app/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/@octokit/auth-oauth-device": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-6.1.0.tgz", - "integrity": "sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.1.tgz", + "integrity": "sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw==", "license": "MIT", "dependencies": { - "@octokit/oauth-methods": "^4.1.0", - "@octokit/request": "^8.3.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/oauth-methods": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, + "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^25.1.0" } }, + "node_modules/@octokit/auth-oauth-device/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/@octokit/auth-oauth-user": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-4.1.0.tgz", - "integrity": "sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.0.tgz", + "integrity": "sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A==", "license": "MIT", "dependencies": { - "@octokit/auth-oauth-device": "^6.1.0", - "@octokit/oauth-methods": "^4.1.0", - "@octokit/request": "^8.3.1", - "@octokit/types": "^13.0.0", - "btoa-lite": "^1.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/auth-oauth-device": "^8.0.1", + "@octokit/oauth-methods": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, + "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^25.1.0" } }, + "node_modules/@octokit/auth-oauth-user/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -2248,50 +2922,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/auth-oauth-app": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.1.tgz", - "integrity": "sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g==", - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.1", - "@octokit/auth-oauth-user": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/auth-oauth-device": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.1.tgz", - "integrity": "sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw==", - "dependencies": { - "@octokit/oauth-methods": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/auth-oauth-user": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.0.tgz", - "integrity": "sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A==", - "dependencies": { - "@octokit/auth-oauth-device": "^8.0.1", - "@octokit/oauth-methods": "^6.0.0", - "@octokit/request": "^10.0.2", - "@octokit/types": "^14.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@octokit/oauth-app/node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -2354,28 +2984,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/oauth-authorization-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", - "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/oauth-methods": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.0.tgz", - "integrity": "sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA==", - "dependencies": { - "@octokit/oauth-authorization-url": "^8.0.0", - "@octokit/request": "^10.0.2", - "@octokit/request-error": "^7.0.0", - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@octokit/oauth-app/node_modules/@octokit/openapi-types": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", @@ -2426,45 +3034,91 @@ "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" }, "node_modules/@octokit/oauth-authorization-url": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz", - "integrity": "sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", + "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/oauth-methods": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-4.1.0.tgz", - "integrity": "sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.0.tgz", + "integrity": "sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA==", "license": "MIT", "dependencies": { - "@octokit/oauth-authorization-url": "^6.0.2", - "@octokit/request": "^8.3.1", - "@octokit/request-error": "^5.1.0", - "@octokit/types": "^13.0.0", - "btoa-lite": "^1.0.0" + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods/node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { - "version": "23.0.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", - "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, + "node_modules/@octokit/oauth-methods/node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods/node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@octokit/oauth-methods/node_modules/@octokit/types": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", - "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^23.0.1" + "@octokit/openapi-types": "^25.1.0" } }, + "node_modules/@octokit/oauth-methods/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/@octokit/openapi-types": { "version": "20.0.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", @@ -3506,6 +4160,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@travi/any": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@travi/any/-/any-3.1.2.tgz", @@ -3675,9 +4338,9 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", - "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { "@types/ms": "*", @@ -4377,6 +5040,7 @@ "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/before-after-hook": { @@ -4443,15 +5107,6 @@ "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -4623,7 +5278,6 @@ "version": "1.0.30001615", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001615.tgz", "integrity": "sha512-1IpazM5G3r38meiae0bHRnPhz+CBQ3ZLqbQMtrg+AsTPKAXgW38JNsXkyZ+v8waCsDmPq87lmfun5Q2AGysNEQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -4752,6 +5406,12 @@ "node": ">=6" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4791,6 +5451,20 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "dev": true }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -4804,7 +5478,38 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "devOptional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/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==", + "license": "MIT", + "optional": true }, "node_modules/colorette": { "version": "2.0.20", @@ -5047,6 +5752,69 @@ "node": ">=18" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -5124,11 +5892,12 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5247,6 +6016,15 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5256,6 +6034,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -9691,12 +10479,12 @@ } }, "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } @@ -10101,12 +10889,12 @@ } }, "node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -10142,9 +10930,28 @@ "license": "MIT" }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } }, "node_modules/natural-compare": { "version": "1.4.0", @@ -10160,6 +10967,58 @@ "node": ">= 0.6" } }, + "node_modules/next": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", + "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.2", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.2", + "@next/swc-darwin-x64": "15.5.2", + "@next/swc-linux-arm64-gnu": "15.5.2", + "@next/swc-linux-arm64-musl": "15.5.2", + "@next/swc-linux-x64-gnu": "15.5.2", + "@next/swc-linux-x64-musl": "15.5.2", + "@next/swc-win32-arm64-msvc": "15.5.2", + "@next/swc-win32-x64-msvc": "15.5.2", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -10599,6 +11458,172 @@ "@octokit/core": ">=5" } }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-app": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.1.4.tgz", + "integrity": "sha512-QkXkSOHZK4dA5oUqY5Dk3S+5pN2s1igPjEASNQV8/vgJgW034fQWR16u7VsNOK/EljA00eyjYF5mWNxWKWhHRQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^7.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "deprecation": "^2.3.1", + "lru-cache": "npm:@wolfy1339/lru-cache@^11.0.2-patch.1", + "universal-github-app-jwt": "^1.1.2", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-app/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-app": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-7.1.0.tgz", + "integrity": "sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "@types/btoa-lite": "^1.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-app/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-device": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-6.1.0.tgz", + "integrity": "sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-device/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-user": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-4.1.0.tgz", + "integrity": "sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/auth-oauth-user/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/oauth-authorization-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz", + "integrity": "sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/oauth-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-4.1.0.tgz", + "integrity": "sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/oauth-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/octokit-auth-probot/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/octokit-auth-probot/node_modules/lru-cache": { + "name": "@wolfy1339/lru-cache", + "version": "11.0.2-patch.1", + "resolved": "https://registry.npmjs.org/@wolfy1339/lru-cache/-/lru-cache-11.0.2-patch.1.tgz", + "integrity": "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==", + "license": "ISC", + "engines": { + "node": "18 >=18.20 || 20 || >=22" + } + }, + "node_modules/octokit-auth-probot/node_modules/universal-github-app-jwt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.2.0.tgz", + "integrity": "sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.0", + "jsonwebtoken": "^9.0.2" + } + }, "node_modules/octokit/node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -11034,7 +12059,6 @@ "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": { @@ -11293,6 +12317,34 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -11592,6 +12644,29 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -12019,6 +13094,13 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -12091,11 +13173,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/send/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==" - }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -12170,6 +13247,62 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12284,6 +13417,23 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -12347,6 +13497,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -12814,6 +13973,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -12837,6 +14019,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -13033,10 +14228,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -13209,14 +14404,10 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/universal-github-app-jwt": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.2.0.tgz", - "integrity": "sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==", - "license": "MIT", - "dependencies": { - "@types/jsonwebtoken": "^9.0.0", - "jsonwebtoken": "^9.0.2" - } + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", + "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", + "license": "MIT" }, "node_modules/universal-user-agent": { "version": "6.0.1", @@ -13287,6 +14478,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index fbfa284da..427fef917 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,31 @@ "test:me": "jest ", "test:unit:watch": "npm run test:unit -- --watch", "test:integration": "jest --roots=lib --roots=test/integration", - "test:integration:debug": "LOG_LEVEL=debug DEBUG=nock run-s test:integration" + "test:integration:debug": "LOG_LEVEL=debug DEBUG=nock run-s test:integration", + "smoke-test": "node smoke-test.js", + "smoke-test:interactive": "node smoke-test.js --interactive", + "smoke-test:phase": "node smoke-test.js --phase" }, "author": "Yadhav Jayaraman", "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^12.0.2", + "@octokit/auth-app": "^8.0.2", "@probot/adapter-aws-lambda-serverless": "^4.0.3", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", "deepmerge": "^4.3.1", "eta": "^3.5.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "minimatch": "^10.0.1", + "minimatch": "^10.0.3", + "next": "^15.5.2", "node-cron": "^3.0.2", "octokit": "^5.0.2", "probot": "^13.4.4", "proxy-from-env": "^1.1.0", + "swr": "^2.4.1", "undici": "^7.7.0" }, "devDependencies": { diff --git a/safe-settings.log b/safe-settings.log new file mode 100644 index 000000000..962c13d3d --- /dev/null +++ b/safe-settings.log @@ -0,0 +1,54 @@ +2025-09-11T01:43:41.125Z [INFO] Received 'pull_request.closed' event: 45 +2025-09-11T01:43:41.125Z [INFO] Pull request closed on Safe-Settings Hub: (jefeish-training/safe-settings-config-master) +2025-09-11T01:43:42.072Z [INFO] Files changed in PR #45: .github/safe-settings/globals/suborg.yml +2025-09-11T01:43:42.072Z [DEBUG] Detected changes in the globals folder. Routing to syncHubGlobalsUpdate(...). +2025-09-11T01:43:42.073Z [INFO] Syncing safe settings for 'globals/'. +2025-09-11T01:43:42.359Z [DEBUG] Loaded manifest.yml rules from hub repo:{ + "rules": [ + { + "name": "global-defaults", + "targets": [ + "*" + ], + "files": [ + "*.yml" + ], + "mergeStrategy": "merge" + }, + { + "name": "security-policies", + "targets": [ + "acme-*", + "foo-bar" + ], + "files": [ + "settings.yml" + ], + "mergeStrategy": "overwrite" + } + ] +} +2025-09-11T01:43:42.361Z [DEBUG] Evaluating globals file: .github/safe-settings/globals/suborg.yml +2025-09-11T01:43:42.361Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'jetest99' with mergeStrategy='merge' +2025-09-11T01:43:42.361Z [DEBUG] Rule 'global-defaults' matches file 'suborg.yml'. Targets: jetest99, jefeish-training, jefeish-test1, copilot-for-emus, jefeish-migration-test, decyjphr-training, decyjphr-emu +2025-09-11T01:43:42.988Z [DEBUG] Checking existence of .github/suborg.yml in jetest99/safe-settings-config +2025-09-11T01:43:43.292Z [INFO] Skipping sync of suborg.yml to jetest99 (already exists & mergeStrategy=merge) +2025-09-11T01:43:43.292Z [DEBUG] Found .github/suborg.yml in jetest99/safe-settings-config +2025-09-11T01:43:43.292Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'jefeish-training' with mergeStrategy='merge' +2025-09-11T01:43:43.773Z [DEBUG] Checking existence of .github/suborg.yml in jefeish-training/safe-settings-config +2025-09-11T01:43:44.077Z [DEBUG] Found .github/suborg.yml in jefeish-training/safe-settings-config +2025-09-11T01:43:44.078Z [INFO] Skipping sync of suborg.yml to jefeish-training (already exists & mergeStrategy=merge) +2025-09-11T01:43:44.078Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'jefeish-test1' with mergeStrategy='merge' +2025-09-11T01:43:44.793Z [DEBUG] Checking existence of .github/suborg.yml in jefeish-test1/safe-settings-config +2025-09-11T01:43:45.082Z [DEBUG] Found .github/suborg.yml in jefeish-test1/safe-settings-config +2025-09-11T01:43:45.082Z [INFO] Skipping sync of suborg.yml to jefeish-test1 (already exists & mergeStrategy=merge) +2025-09-11T01:43:45.082Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'copilot-for-emus' with mergeStrategy='merge' +2025-09-11T01:43:45.593Z [INFO] Skipping org copilot-for-emus: config repo 'safe-settings-config' does not exist. +2025-09-11T01:43:45.593Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'jefeish-migration-test' with mergeStrategy='merge' +2025-09-11T01:43:46.208Z [DEBUG] Checking existence of .github/suborg.yml in jefeish-migration-test/safe-settings-config +2025-09-11T01:43:46.461Z [INFO] Skipping sync of suborg.yml to jefeish-migration-test (already exists & mergeStrategy=merge) +2025-09-11T01:43:46.461Z [DEBUG] Found .github/suborg.yml in jefeish-migration-test/safe-settings-config +2025-09-11T01:43:46.461Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'decyjphr-training' with mergeStrategy='merge' +2025-09-11T01:43:46.897Z [INFO] Skipping org decyjphr-training: config repo 'safe-settings-config' does not exist. +2025-09-11T01:43:46.897Z [DEBUG] Preparing to sync file 'suborg.yml' to org 'decyjphr-emu' with mergeStrategy='merge' +2025-09-11T01:43:47.342Z [INFO] Skipping org decyjphr-emu: config repo 'safe-settings-config' does not exist. diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index e94a66e57..d4955ad26 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -39,7 +39,17 @@ "properties": { "advanced_security": { "type": "object", - "description": "Use the `status` property to enable or disable GitHub Advanced Security for this repository. For more information, see \"[About GitHub Advanced Security](/github/getting-started-with-github/learning-about-github/about-github-advanced-security).\"", + "description": "Use the `status` property to enable or disable GitHub Advanced Security for this repository.\nFor more information, see \"[About GitHub Advanced\nSecurity](/github/getting-started-with-github/learning-about-github/about-github-advanced-security).\"\n\nFor standalone Code Scanning or Secret Protection products, this parameter cannot be used.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "code_security": { + "type": "object", + "description": "Use the `status` property to enable or disable GitHub Code Security for this repository.", "properties": { "status": { "type": "string", @@ -67,6 +77,16 @@ } } }, + "secret_scanning_ai_detection": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning AI detection for this repository. For more information, see \"[Responsible detection of generic secrets with AI](https://docs.github.com/code-security/secret-scanning/using-advanced-secret-scanning-and-push-protection-features/generic-secret-detection/responsible-ai-generic-secrets).\"", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, "secret_scanning_non_provider_patterns": { "type": "object", "description": "Use the `status` property to enable or disable secret scanning non-provider patterns for this repository. For more information, see \"[Supported secret scanning patterns](/code-security/secret-scanning/introduction/supported-secret-scanning-patterns#supported-secrets).\"", @@ -76,6 +96,66 @@ "description": "Can be `enabled` or `disabled`." } } + }, + "secret_scanning_delegated_alert_dismissal": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated alert dismissal for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass": { + "type": "object", + "description": "Use the `status` property to enable or disable secret scanning delegated bypass for this repository.", + "properties": { + "status": { + "type": "string", + "description": "Can be `enabled` or `disabled`." + } + } + }, + "secret_scanning_delegated_bypass_options": { + "type": "object", + "description": "Feature options for secret scanning delegated bypass.\nThis object is only honored when `security_and_analysis.secret_scanning_delegated_bypass.status` is set to `enabled`.\nYou can send this object in the same request as `secret_scanning_delegated_bypass`, or update just the options in a separate request.", + "properties": { + "reviewers": { + "type": "array", + "description": "The bypass reviewers for secret scanning delegated bypass.\nIf you omit this field, the existing set of reviewers is unchanged.", + "items": { + "type": "object", + "required": [ + "reviewer_id", + "reviewer_type" + ], + "properties": { + "reviewer_id": { + "type": "integer", + "description": "The ID of the team or role selected as a bypass reviewer" + }, + "reviewer_type": { + "type": "string", + "description": "The type of the bypass reviewer", + "enum": [ + "TEAM", + "ROLE" + ] + }, + "mode": { + "type": "string", + "description": "The bypass mode for the reviewer", + "enum": [ + "ALWAYS", + "EXEMPT" + ], + "default": "ALWAYS" + } + } + } + } + } } } }, @@ -94,6 +174,19 @@ "description": "Either `true` to enable the wiki for this repository or `false` to disable it.", "default": true }, + "has_pull_requests": { + "type": "boolean", + "description": "Either `true` to allow pull requests for this repository or `false` to prevent pull requests.", + "default": true + }, + "pull_request_creation_policy": { + "type": "string", + "description": "The policy that controls who can create pull requests for this repository: `all` or `collaborators_only`.", + "enum": [ + "all", + "collaborators_only" + ] + }, "is_template": { "type": "boolean", "description": "Either `true` to make this repo available as a template repository or `false` to prevent it.", @@ -135,7 +228,7 @@ }, "use_squash_pr_title_as_default": { "type": "boolean", - "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property has been deprecated. Please use `squash_merge_commit_title` instead.", + "description": "Either `true` to allow squash-merge commits to use pull request title, or `false` to use commit message. **This property is closing down. Please use `squash_merge_commit_title` instead.", "default": false, "deprecated": true }, @@ -338,7 +431,7 @@ }, "maintainers": { "type": "array", - "description": "List GitHub IDs for organization members who will become team maintainers.", + "description": "List GitHub usernames for organization members who will become team maintainers.", "items": { "type": "string" } @@ -368,7 +461,7 @@ }, "permission": { "type": "string", - "description": "**Deprecated**. The permission that new repositories will be added to the team with when none is specified.", + "description": "**Closing down notice**. The permission that new repositories will be added to the team with when none is specified.", "enum": [ "pull", "push" @@ -409,7 +502,7 @@ "contexts": { "type": "array", "deprecated": true, - "description": "**Deprecated**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", + "description": "**Closing down notice**: The list of status checks to require in order to merge into this branch. If any of these checks have recently been set by a particular GitHub App, they will be required to come from that app in future for the branch to merge. Use `checks` instead of `contexts` for more fine-grained control.", "items": { "type": "string" } @@ -663,7 +756,8 @@ "enum": [ "branch", "tag", - "push" + "push", + "repository" ], "default": "branch" }, @@ -690,7 +784,7 @@ "actor_id": { "type": "integer", "nullable": true, - "description": "The ID of the actor that can bypass a ruleset. If `actor_type` is `OrganizationAdmin`, this should be `1`. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." }, "actor_type": { "type": "string", @@ -699,18 +793,24 @@ "OrganizationAdmin", "RepositoryRole", "Team", - "DeployKey" + "DeployKey", + "User" ], "description": "The type of actor that can bypass a ruleset." }, "bypass_mode": { "type": "string", - "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets.", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", "enum": [ "always", - "pull_request" + "pull_request", + "exempt" ], "default": "always" + }, + "name": { + "type": "string", + "description": "Human-friendly alternative to `actor_id`. The team slug, username, GitHub App slug, or repository role name (resolved using `actor_type`). Cannot be combined with `actor_id`." } } } @@ -718,7 +818,7 @@ "conditions": { "title": "Organization ruleset conditions", "type": "object", - "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", "oneOf": [ { "type": "object", @@ -1043,83 +1143,6 @@ } } }, - { - "title": "merge_queue", - "description": "Merges must be performed via a merge queue.", - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "merge_queue" - ] - }, - "parameters": { - "type": "object", - "properties": { - "check_response_timeout_minutes": { - "type": "integer", - "description": "Maximum time for a required status check to report a conclusion. After this much time has elapsed, checks that have not reported a conclusion will be assumed to have failed", - "minimum": 1, - "maximum": 360 - }, - "grouping_strategy": { - "type": "string", - "description": "When set to ALLGREEN, the merge commit created by merge queue for each PR in the group must pass all required checks to merge. When set to HEADGREEN, only the commit at the head of the merge group, i.e. the commit containing changes from all of the PRs in the group, must pass its required checks to merge.", - "enum": [ - "ALLGREEN", - "HEADGREEN" - ] - }, - "max_entries_to_build": { - "type": "integer", - "description": "Limit the number of queued pull requests requesting checks and workflow runs at the same time.", - "minimum": 0, - "maximum": 100 - }, - "max_entries_to_merge": { - "type": "integer", - "description": "The maximum number of PRs that will be merged together in a group.", - "minimum": 0, - "maximum": 100 - }, - "merge_method": { - "type": "string", - "description": "Method to use when merging changes from queued pull requests.", - "enum": [ - "MERGE", - "SQUASH", - "REBASE" - ] - }, - "min_entries_to_merge": { - "type": "integer", - "description": "The minimum number of PRs that will be merged together in a group.", - "minimum": 0, - "maximum": 100 - }, - "min_entries_to_merge_wait_minutes": { - "type": "integer", - "description": "The time merge queue should wait after the first PR is added to the queue for the minimum group size to be met. After this time has elapsed, the minimum group size will be ignored and a smaller group will be merged.", - "minimum": 0, - "maximum": 360 - } - }, - "required": [ - "check_response_timeout_minutes", - "grouping_strategy", - "max_entries_to_build", - "max_entries_to_merge", - "merge_method", - "min_entries_to_merge", - "min_entries_to_merge_wait_minutes" - ] - } - } - }, { "title": "required_deployments", "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", @@ -1184,6 +1207,18 @@ "parameters": { "type": "object", "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, "dismiss_stale_reviews_on_push": { "type": "boolean", "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." @@ -1205,6 +1240,58 @@ "required_review_thread_resolution": { "type": "boolean", "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + }, + "slug": { + "type": "string", + "description": "Human-friendly alternative to `id`. The slug of the team that must review changes to matching files. Cannot be combined with `id`." + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } } }, "required": [ @@ -1307,7 +1394,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1354,7 +1441,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1401,7 +1488,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1448,7 +1535,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1495,7 +1582,7 @@ "properties": { "name": { "type": "string", - "description": "How this rule will appear to users." + "description": "How this rule appears when configuring it." }, "negate": { "type": "boolean", @@ -1525,7 +1612,7 @@ }, { "title": "file_path_restriction", - "description": "Prevent commits that include changes in specified file paths from being pushed to the commit graph.", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", "type": "object", "required": [ "type" @@ -1556,7 +1643,7 @@ }, { "title": "max_file_path_length", - "description": "Prevent commits that include file paths that exceed a specified character limit from being pushed to the commit graph.", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", "type": "object", "required": [ "type" @@ -1573,9 +1660,9 @@ "properties": { "max_file_path_length": { "type": "integer", - "description": "The maximum amount of characters allowed in file paths", + "description": "The maximum amount of characters allowed in file paths.", "minimum": 1, - "maximum": 256 + "maximum": 32767 } }, "required": [ @@ -1617,7 +1704,7 @@ }, { "title": "max_file_size", - "description": "Prevent commits that exceed a specified file size limit from being pushed to the commit.", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", "type": "object", "required": [ "type" @@ -1768,6 +1855,35 @@ ] } } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } } ] } @@ -1778,6 +1894,131 @@ "enforcement" ] } + }, + "custom_repository_roles": { + "description": "Org-level custom repository roles. Only valid in the org-level settings.yml.", + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "base_role", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the custom role." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "A short description of the role." + }, + "base_role": { + "type": "string", + "enum": [ + "read", + "triage", + "write", + "maintain" + ], + "description": "The system role from which this role inherits permissions." + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional fine-grained permissions included in this role." + } + } + } + }, + "additive_plugins": { + "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). Declare only in settings.yml (org level) to keep behavior consistent across all repos.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "labels", + "collaborators", + "teams", + "milestones", + "autolinks", + "environments", + "custom_properties", + "variables", + "rulesets", + "custom_repository_roles" + ] + } + }, + "disable_plugins": { + "description": "List of plugins to disable at this configuration layer. Each entry is either a plugin name (string shorthand, equivalent to target: all) or an object {plugin, target}. target=self disables the plugin at this layer only; target=children disables it at all lower layers; target=all disables it at this layer and all lower layers. Cascade is union-only; lower layers cannot re-enable a disabled plugin.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "enum": [ + "repository", + "labels", + "collaborators", + "teams", + "milestones", + "branches", + "autolinks", + "validator", + "rulesets", + "environments", + "custom_properties", + "custom_repository_roles", + "variables", + "archive" + ] + }, + { + "type": "object", + "required": [ + "plugin" + ], + "additionalProperties": false, + "properties": { + "plugin": { + "type": "string", + "enum": [ + "repository", + "labels", + "collaborators", + "teams", + "milestones", + "branches", + "autolinks", + "validator", + "rulesets", + "environments", + "custom_properties", + "custom_repository_roles", + "variables", + "archive" + ] + }, + "target": { + "type": "string", + "enum": [ + "self", + "children", + "all" + ], + "default": "all" + } + } + } + ] + } } } } \ No newline at end of file diff --git a/schema/settings.json b/schema/settings.json index 4d390b38f..57565fd83 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -191,6 +191,131 @@ "items": { "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.2022-11-28.json#/paths/~1orgs~1{org}~1rulesets/post/requestBody/content/application~1json/schema" } + }, + "custom_repository_roles": { + "description": "Org-level custom repository roles. Only valid in the org-level settings.yml.", + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "base_role", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the custom role." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "A short description of the role." + }, + "base_role": { + "type": "string", + "enum": [ + "read", + "triage", + "write", + "maintain" + ], + "description": "The system role from which this role inherits permissions." + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional fine-grained permissions included in this role." + } + } + } + }, + "additive_plugins": { + "description": "List of Diffable plugins to run in additive mode. In additive mode the plugin will only add and update entries; it will never call remove(), so items that exist on GitHub but are absent from the YAML are preserved. Only Diffable-extending plugins are supported (labels, collaborators, teams, milestones, autolinks, environments, custom_properties, variables, rulesets, custom_repository_roles). Declare only in settings.yml (org level) to keep behavior consistent across all repos.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "labels", + "collaborators", + "teams", + "milestones", + "autolinks", + "environments", + "custom_properties", + "variables", + "rulesets", + "custom_repository_roles" + ] + } + }, + "disable_plugins": { + "description": "List of plugins to disable at this configuration layer. Each entry is either a plugin name (string shorthand, equivalent to target: all) or an object {plugin, target}. target=self disables the plugin at this layer only; target=children disables it at all lower layers; target=all disables it at this layer and all lower layers. Cascade is union-only; lower layers cannot re-enable a disabled plugin.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "enum": [ + "repository", + "labels", + "collaborators", + "teams", + "milestones", + "branches", + "autolinks", + "validator", + "rulesets", + "environments", + "custom_properties", + "custom_repository_roles", + "variables", + "archive" + ] + }, + { + "type": "object", + "required": [ + "plugin" + ], + "additionalProperties": false, + "properties": { + "plugin": { + "type": "string", + "enum": [ + "repository", + "labels", + "collaborators", + "teams", + "milestones", + "branches", + "autolinks", + "validator", + "rulesets", + "environments", + "custom_properties", + "custom_repository_roles", + "variables", + "archive" + ] + }, + "target": { + "type": "string", + "enum": [ + "self", + "children", + "all" + ], + "default": "all" + } + } + } + ] + } } } -} +} \ No newline at end of file diff --git a/script/build-schema b/script/build-schema index 7611d089e..68b59dd20 100755 --- a/script/build-schema +++ b/script/build-schema @@ -3,11 +3,54 @@ const $RefParser = require('@apidevtools/json-schema-ref-parser') const fs = require('node:fs/promises'); +// Safe-settings lets users reference ruleset bypass actors and required +// reviewers by name instead of numeric id. GitHub's published schema only +// knows about the id fields, so after dereferencing we inject the optional +// name-based alternatives: +// - bypass_actors[].name -> alternative to actor_id (team slug, username, +// GitHub App slug, or repository role name; resolved via actor_type) +// - reviewer.slug -> alternative to reviewer.id (team slug) +const augmentRulesetAliases = (node) => { + if (Array.isArray(node)) { + node.forEach(augmentRulesetAliases) + return + } + if (!node || typeof node !== 'object') return + + const props = node.properties + if (props && typeof props === 'object') { + // Bypass actor object: has both actor_type and actor_id. + if (props.actor_type && props.actor_id && !props.name) { + props.name = { + type: 'string', + description: 'Human-friendly alternative to `actor_id`. The team slug, username, GitHub App slug, or repository role name (resolved using `actor_type`). Cannot be combined with `actor_id`.' + } + } + // Reviewer object: has id and a type enum restricted to Team. + if (props.id && props.type && Array.isArray(props.type.enum) && + props.type.enum.length === 1 && props.type.enum[0] === 'Team' && !props.slug) { + props.slug = { + type: 'string', + description: 'Human-friendly alternative to `id`. The slug of the team that must review changes to matching files. Cannot be combined with `id`.' + } + if (Array.isArray(node.required)) { + node.required = node.required.filter(r => r !== 'id') + } + } + } + + for (const value of Object.values(node)) { + if (value && typeof value === 'object') augmentRulesetAliases(value) + } +}; + (async () => { const schema = await fs.readFile('schema/settings.json', 'utf-8').then(JSON.parse) await $RefParser.dereference(schema) + augmentRulesetAliases(schema) + await fs.mkdir('schema/dereferenced', { recursive: true }) await fs.writeFile('schema/dereferenced/settings.json', JSON.stringify(schema, null, 2)) })().catch(console.error) diff --git a/smoke-test.js b/smoke-test.js new file mode 100644 index 000000000..00ca7d30c --- /dev/null +++ b/smoke-test.js @@ -0,0 +1,2749 @@ +#!/usr/bin/env node + +/** + * Smoke Test for safe-settings + * + * Usage: + * 1. Ensure `.env` is configured with GH_ORG, APP_ID, PRIVATE_KEY, WEBHOOK_PROXY_URL, etc. + * 2. Set GH_TOKEN env var to a fine-grained PAT with org admin + repo permissions. + * This is required for drift-remediation tests (Phases 2 & 3) so that + * changes appear as a human (not Bot) and trigger safe-settings webhooks. + * 3. Run: `node smoke-test.js` + * Add --interactive to pause after each phase for manual validation. + * Set SMOKE_VERBOSE=1 for live safe-settings logs. + * Optional (Phase 16 — ruleset name resolution): set SMOKE_NR_USER to a + * username and/or SMOKE_NR_APP_SLUG to an installed GitHub App slug to also + * exercise User and Integration bypass-actor name resolution. + * + * Auth: + * - Octokit (GitHub App): APP_ID + PRIVATE_KEY from .env — used for most operations. + * - gh CLI (user PAT): GH_TOKEN env var — used for drift tests only. + */ + +const { execSync, spawn } = require('child_process') +const fs = require('fs') +const path = require('path') +const readline = require('readline') + +// ─── Configuration ─────────────────────────────────────────────────────────── + +function loadEnv () { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) throw new Error('.env file not found') + const lines = fs.readFileSync(envPath, 'utf8').split('\n') + let currentKey = null + let currentValue = '' + let inMultiline = false + + for (const line of lines) { + if (inMultiline) { + currentValue += '\n' + line + if (line.includes('"') || line.includes("'")) { + const val = currentValue.replace(/^["']|["']$/g, '') + // Like dotenv: .env values don't override existing env vars + if (!(currentKey in process.env)) process.env[currentKey] = val + inMultiline = false + } + continue + } + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + currentKey = trimmed.slice(0, eqIdx).trim() + currentValue = trimmed.slice(eqIdx + 1).trim() + if ((currentValue.startsWith('"') && !currentValue.endsWith('"')) || + (currentValue.startsWith("'") && !currentValue.endsWith("'"))) { + inMultiline = true + continue + } + const val = currentValue.replace(/^["']|["']$/g, '') + if (!(currentKey in process.env)) process.env[currentKey] = val + } +} + +loadEnv() + +const ORG = process.env.GH_ORG || 'decyjphr-emu' +const ADMIN_REPO = process.env.ADMIN_REPO || 'admin' +const CONFIG_PATH = process.env.CONFIG_PATH || '.github' +const APP_ID = process.env.APP_ID +const PRIVATE_KEY = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n') + +const TEST_REPOS = ['test', 'demo-repo-service1', 'demo-repo-service2', 'combined-settings-repo'] +const TEST_TEAMS = ['AD-GRP-PAYMENTS-PLATFORM-OWNERS', 'awesometeam-a-approvers', 'jefeish-edj-test'] + +// Principals created on demand for the ruleset name-resolution phase (Phase 16) +const SMOKE_NR_TEAM = 'safe-settings-smoke-nr-team' +const SMOKE_NR_ROLE = 'safe-settings-smoke-nr-role' + +const POLL_INTERVAL_MS = 5000 +const MAX_POLL_MS = 120000 +const WEBHOOK_SETTLE_MS = 15000 + +// Fine-grained PAT for drift tests (must appear as a human, not Bot) +const GH_TOKEN = process.env.GH_TOKEN || '' + +// Interactive mode: pause after each phase for manual validation +// Accepts --interactive flag or bare positional "interactive" word. +const INTERACTIVE = process.argv.includes('--interactive') || process.argv.slice(2).includes('interactive') + +// Phase filter: supports single, comma-separated, or range values. +// --phase 3 → only phase 3 +// --phase 1,2,3 → phases 1, 2, and 3 +// --phase 1-3 → phases 1 through 3 +// npm run smoke-test:phase -- 1-3 interactive +const PHASE_ARG_IDX = process.argv.indexOf('--phase') +const _parsePhaseSet = (raw) => { + if (!raw) return null + const nums = new Set() + for (const part of raw.split(',')) { + const range = part.match(/^(\d+)-(\d+)$/) + if (range) { + const lo = parseInt(range[1], 10) + const hi = parseInt(range[2], 10) + for (let i = lo; i <= hi; i++) nums.add(i) + } else if (/^\d+$/.test(part.trim())) { + nums.add(parseInt(part.trim(), 10)) + } + } + return nums.size > 0 ? nums : null +} +const ONLY_PHASES = PHASE_ARG_IDX !== -1 + ? _parsePhaseSet(process.argv[PHASE_ARG_IDX + 1]) + : (() => { + // Accept bare positional phase spec (e.g. "3" or "1-3" or "1,2,3") + const positional = process.argv.slice(2).find(a => !a.startsWith('--') && /^[\d,\-]+$/.test(a) && !/^-\d/.test(a)) + return positional !== undefined ? _parsePhaseSet(positional) : null + })() + +class InteractiveExit extends Error { + constructor (action) { + super(`interactive:${action}`) + this.action = action + } +} + +// ─── Octokit client (initialized in main) ──────────────────────────────────── + +let octokit = null + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +let passCount = 0 +let failCount = 0 +const failures = [] + +function log (msg) { console.log(`\x1b[36m[smoke]\x1b[0m ${msg}`) } +function logPass (msg) { passCount++; console.log(`\x1b[32m ✓ ${msg}\x1b[0m`) } +function logFail (msg) { failCount++; failures.push(msg); console.log(`\x1b[31m ✗ ${msg}\x1b[0m`) } +function logPhase (msg) { console.log(`\n\x1b[35m═══ ${msg} ═══\x1b[0m`) } + +function assert (condition, msg) { + if (condition) logPass(msg) + else logFail(msg) + return condition +} + +function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)) } + +async function poll (fn, { timeout = MAX_POLL_MS, interval = POLL_INTERVAL_MS, desc = 'condition' } = {}) { + const start = Date.now() + while (Date.now() - start < timeout) { + const result = await fn() + if (result) return result + await sleep(interval) + } + log(` ⚠ Timed out waiting for ${desc}`) + return null +} + +// ─── Interactive mode ───────────────────────────────────────────────────────── + +let skipNext = false + +async function pause (phaseName) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + process.stdout.write( + `\n\x1b[33m[interactive] "${phaseName}" complete.\x1b[0m\n` + + ` \x1b[90mPress Enter to continue, 's' skip next, 'q' quit+teardown, 'a' abort: \x1b[0m` + ) + rl.once('line', (answer) => { + const input = answer.trim().toLowerCase() + if (input === 's') resolve('skip') + else if (input === 'q') resolve('quit') + else if (input === 'a') resolve('abort') + else resolve('continue') + rl.close() + }) + rl.once('close', () => resolve('continue')) + }) +} + +async function runPhase (label, fn) { + if (skipNext) { + log(`\x1b[33m[interactive] Skipping ${label}\x1b[0m`) + skipNext = false + return 'skipped' + } + await fn() + if (!INTERACTIVE) return 'continue' + const action = await pause(label) + if (action === 'skip') skipNext = true + return action +} + +async function confirmMerge (owner, repo, prNumber) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + process.stdout.write( + `\n\x1b[33m[interactive] PR #${prNumber} is ready to merge.\x1b[0m\n` + + ` \x1b[90mPress Enter to merge, 'c' to close PR, 'q' quit+teardown, 'a' abort: \x1b[0m` + ) + rl.once('line', (answer) => { + const input = answer.trim().toLowerCase() + if (input === 'c') resolve('close') + else if (input === 'q') resolve('quit') + else if (input === 'a') resolve('abort') + else resolve('merge') + rl.close() + }) + rl.once('close', () => resolve('merge')) + }) +} + +async function safeMerge (owner, repo, prNumber) { + if (INTERACTIVE) { + const action = await confirmMerge(owner, repo, prNumber) + if (action !== 'merge') { + try { await octokit.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }) } catch { /* ok */ } + log(`\x1b[33m[interactive] PR #${prNumber} closed.\x1b[0m`) + if (action === 'quit' || action === 'abort') throw new InteractiveExit(action) + return false + } + } + log('Merging PR...') + await mergePR(owner, repo, prNumber) + return true +} + +// ─── GitHub API helpers ────────────────────────────────────────────────────── + +async function getDefaultBranch () { + const { data } = await octokit.rest.repos.get({ owner: ORG, repo: ADMIN_REPO }) + return data.default_branch || 'main' +} + +async function createOrUpdateFile (owner, repo, filePath, content, branch, message) { + const b64 = Buffer.from(content).toString('base64') + let sha = null + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch }) + sha = data.sha + } catch { /* file doesn't exist */ } + const params = { owner, repo, path: filePath, message, content: b64, branch } + if (sha) params.sha = sha + return (await octokit.rest.repos.createOrUpdateFileContents(params)).data +} + +async function deleteFile (owner, repo, filePath, branch, message) { + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath, ref: branch }) + await octokit.rest.repos.deleteFile({ owner, repo, path: filePath, message, sha: data.sha, branch }) + } catch { /* file doesn't exist */ } +} + +async function cleanDirectory (owner, repo, dirPath) { + const branch = await getDefaultBranch() + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path: dirPath, ref: branch }) + if (Array.isArray(data)) { + for (const file of data) { + if (file.type === 'file') { + await deleteFile(owner, repo, file.path, branch, `Clean up ${file.path}`) + } + } + } + } catch { /* directory doesn't exist */ } +} + +async function createBranch (owner, repo, branchName) { + const defaultBranch = await getDefaultBranch() + const { data: ref } = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${defaultBranch}` }) + await octokit.rest.git.createRef({ owner, repo, ref: `refs/heads/${branchName}`, sha: ref.object.sha }) +} + +async function deleteBranch (owner, repo, branch) { + try { await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }) } catch { /* ok */ } +} + +async function createPR (owner, repo, title, head, base) { + const { data } = await octokit.rest.pulls.create({ owner, repo, title, head, base, body: `Smoke test: ${title}` }) + log(` Created PR #${data.number}`) + return data +} + +async function mergePR (owner, repo, prNumber) { + return (await octokit.rest.pulls.merge({ owner, repo, pull_number: prNumber, merge_method: 'merge' })).data +} + +async function deleteRepo (owner, repo) { + try { await octokit.rest.repos.delete({ owner, repo }) } catch { /* ok */ } +} + +async function deleteTeam (org, teamSlug) { + try { await octokit.rest.teams.deleteInOrg({ org, team_slug: teamSlug }) } catch { /* ok */ } +} + +async function ensureTeam (org, name) { + try { + const { data } = await octokit.rest.teams.getByName({ org, team_slug: name }) + return data + } catch { /* team doesn't exist yet */ } + try { + const { data } = await octokit.rest.teams.create({ org, name, privacy: 'closed' }) + return data + } catch { return null } +} + +async function getCustomRepositoryRole (org, name) { + try { + const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org }) + return (data.custom_roles || []).find(role => role.name === name) || null + } catch { return null } +} + +async function createCustomRepositoryRole (org, name, description) { + const existing = await getCustomRepositoryRole(org, name) + if (existing) return existing + return (await octokit.request('POST /orgs/{org}/custom-repository-roles', { + org, + name, + description, + base_role: 'read', + permissions: ['delete_alerts_code_scanning'] + })).data +} + +async function deleteCustomRepositoryRole (org, name) { + const role = await getCustomRepositoryRole(org, name) + if (!role) return + await octokit.request('DELETE /orgs/{org}/custom-repository-roles/{role_id}', { org, role_id: role.id }) +} + +async function getOrgRuleset (org, name) { + try { + const { data: rulesets } = await octokit.request('GET /orgs/{org}/rulesets', { org }) + return rulesets.find(ruleset => ruleset.name === name) || null + } catch { return null } +} + +async function getRepoRuleset (owner, repo, name) { + try { + const { data: rulesets } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner, repo }) + return rulesets.find(ruleset => ruleset.name === name) || null + } catch { return null } +} + +async function getRepoRulesetDetails (owner, repo, rulesetId) { + try { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', { owner, repo, ruleset_id: rulesetId }) + return data + } catch { return null } +} + +async function setRepoCustomProperty (owner, repo, propertyName, value) { + await octokit.request('PATCH /repos/{owner}/{repo}/properties/values', { + owner, + repo, + properties: [ + { property_name: propertyName, value } + ] + }) +} + +async function createOrgRuleset (org, name) { + const existing = await getOrgRuleset(org, name) + if (existing) return existing + return (await octokit.request('POST /orgs/{org}/rulesets', { + org, + name, + target: 'repository', + source_type: 'Organization', + source: org, + enforcement: 'disabled', + conditions: { + repository_property: { + exclude: [], + include: [ + { name: 'visibility', source: 'system', property_values: ['private'] } + ] + } + }, + rules: [{ type: 'repository_delete' }] + })).data +} + +async function deleteOrgRuleset (org, name) { + const ruleset = await getOrgRuleset(org, name) + if (!ruleset) return + await octokit.request('DELETE /orgs/{org}/rulesets/{ruleset_id}', { org, ruleset_id: ruleset.id }) +} + +async function waitForCheckRun (owner, repo, sha, { timeout = MAX_POLL_MS } = {}) { + return poll(async () => { + const { data } = await octokit.rest.checks.listForRef({ owner, repo, ref: sha }) + const cr = data.check_runs.find(c => c.name === 'Safe-setting validator') + return (cr && cr.status === 'completed') ? cr : null + }, { timeout, desc: 'check run to complete' }) +} + +// ─── Safe-settings process management ──────────────────────────────────────── + +let ssProcess = null + +function startSafeSettings () { + log('Starting safe-settings...') + ssProcess = spawn('npm', ['start'], { + cwd: __dirname, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + ssProcess.stdout.on('data', (d) => { if (process.env.SMOKE_VERBOSE) process.stdout.write(d) }) + ssProcess.stderr.on('data', (d) => { if (process.env.SMOKE_VERBOSE) process.stderr.write(d) }) + ssProcess.on('exit', (code) => { log(`safe-settings exited with code ${code}`) }) +} + +function stopSafeSettings () { + if (ssProcess) { + log('Stopping safe-settings...') + ssProcess.kill('SIGTERM') + ssProcess = null + } +} + +// ─── YAML Configs ──────────────────────────────────────────────────────────── + +const REPO_TEST_YML = `repository: + name: test + description: Demo repository created via safe-settings + private: true + auto_init: true + force_create: true + has_issues: true + has_projects: false + has_wiki: false + delete_branch_on_merge: true + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: true + +teams: + - name: expert-services-developers + permission: push + +custom_properties: + - property_name: ent-ownership + value: expert-services + - property_name: ent-supervisory-org + value: expert-services + +rulesets: +- name: synk + target: branch + enforcement: disabled + bypass_actors: + - actor_id: 1 + actor_type: OrganizationAdmin + bypass_mode: pull_request + + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: ["refs/heads/oldmaster"] + + rules: + - type: creation + - type: update + - type: deletion + - type: required_linear_history + - type: required_signatures + - type: pull_request + parameters: + dismiss_stale_reviews_on_push: true + require_code_owner_review: true + require_last_push_approval: true + required_approving_review_count: 2 + required_review_thread_resolution: true + + - type: commit_message_pattern + parameters: + name: test commit_message_pattern + negate: true + operator: starts_with + pattern: skip* + + - type: commit_author_email_pattern + parameters: + name: test commit_author_email_pattern + negate: false + operator: regex + pattern: "^.*@example.com$" + + - type: committer_email_pattern + parameters: + name: test committer_email_pattern + negate: false + operator: regex + pattern: "^.*@example.com$" + + - type: branch_name_pattern + parameters: + name: test branch_name_pattern + negate: false + operator: regex + pattern: ".*\\\\/.*" + +- name: Prevent merges when new SONAR alerts are introduced + target: branch + enforcement: active + conditions: + ref_name: + include: + - "~DEFAULT_BRANCH" + exclude: [] + bypass_actors: + - actor_type: OrganizationAdmin + bypass_mode: always + rules: + - type: code_scanning + parameters: + code_scanning_tools: + - tool: Sonar + alerts_threshold: none + security_alerts_threshold: medium_or_higher +` + +const REPO_TEST_OTHER_OWNERSHIP_YML = REPO_TEST_YML.replace( + ' - property_name: ent-ownership\n value: expert-services', + ' - property_name: ent-ownership\n value: other-services' +) + +const REPO_DEMO_SERVICE1_YML = `# Safe-Settings Configuration +repository: + name: demo-repo-service1 + description: "Repository 2 sample" + visibility: private + default_branch: main + homepage: "" + auto_init: true + force_create: true + delete_branch_on_merge: true + archived: false + topics: + - topic1 + - topic2 + +teams: + - name: AD-GRP-PAYMENTS-PLATFORM-OWNERS + permission: admin + - name: awesometeam-a-approvers + permission: push + - name: expert-services-developers + permission: push + +branches: + - name: main + protection: + required_status_checks: + strict: true + contexts: [] + required_pull_request_reviews: + required_approving_review_count: 2 + dismiss_stale_reviews: false + require_code_owner_reviews: true + require_last_push_approval: false + bypass_pull_request_allowances: + apps: [] + users: [] + teams: [] + dismissal_restrictions: + users: [] + teams: [] + enforce_admins: true + restrictions: + apps: [] + users: [] + teams: [] + + - name: develop + protection: + required_status_checks: + strict: true + contexts: [] + required_pull_request_reviews: + required_approving_review_count: 1 + dismiss_stale_reviews: false + require_code_owner_reviews: true + require_last_push_approval: false + bypass_pull_request_allowances: + apps: [] + users: [] + teams: [] + dismissal_restrictions: + users: [] + teams: [] + enforce_admins: true + restrictions: + apps: [] + users: [] + teams: [] +` + +const SUBORG_EXPERT_SERVICES_YML = `suborgteams: + - expert-services-developers + +rulesets: + - name: Protect release and production branches + target: branch + enforcement: active + conditions: + ref_name: + include: + - refs/heads/release/* + - refs/heads/production + exclude: [] + bypass_actors: + - actor_type: OrganizationAdmin + bypass_mode: always + rules: + - type: creation + - type: pull_request + parameters: + required_approving_review_count: 1 + dismiss_stale_reviews_on_push: false + require_code_owner_review: false + require_last_push_approval: false + required_review_thread_resolution: false + allowed_merge_methods: + - merge + - squash + - rebase + required_reviewers: + - minimum_approvals: 1 + file_patterns: + - "*.js" + reviewer: + id: 11721733 + type: Team +` + +const SUBORG_EXPERT_SERVICES_PROPERTY_YML = SUBORG_EXPERT_SERVICES_YML.replace( + 'suborgteams:\n - expert-services-developers', + 'suborgproperties:\n - ent-ownership: expert-services' +) + +const REPO_DEMO_SERVICE1_ARCHIVED_YML = `# Safe-Settings Configuration +repository: + name: demo-repo-service1 + description: "Repository 2 sample" + visibility: private + default_branch: main + homepage: "" + auto_init: true + force_create: true + delete_branch_on_merge: true + archived: true +` + +const REPO_DEMO_SERVICE2_YML = `# Safe-Settings Configuration +repository: + name: demo-repo-service2 + description: "Repository 2 sample" + visibility: private + default_branch: main + homepage: "" + auto_init: true + force_create: true + delete_branch_on_merge: true + archived: false + topics: + - topic1 + - topic2 + +teams: + - name: expert-services-developers + permission: push +` + +const REPO_DEMO_SERVICE2_EXTERNAL_GROUP_YML = `# Safe-Settings Configuration +repository: + name: demo-repo-service2 + description: "Repository 2 sample" + visibility: private + default_branch: main + homepage: "" + auto_init: true + force_create: true + delete_branch_on_merge: true + archived: false + topics: + - topic1 + - topic2 + +teams: + - name: expert-services-developers + permission: push + - name: jefeish-edj-test + permission: push + external_group: jefeish-edj-test +` + +const REPO_DEMO_SERVICE2_NO_EXTERNAL_GROUP_YML = `# Safe-Settings Configuration +repository: + name: demo-repo-service2 + description: "Repository 2 sample" + visibility: private + default_branch: main + homepage: "" + auto_init: true + force_create: true + delete_branch_on_merge: true + archived: false + topics: + - topic1 + - topic2 + +teams: + - name: expert-services-developers + permission: push +` + +const SETTINGS_YML_ORG = `# Org-level safe-settings configuration + +rulesets: + - name: test + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - internal + rules: + - type: repository_delete + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +// Phase 10a: settings.yml that disables custom_repository_roles at org-self, +// and tries to add a NEW role ("disabled-role"). The new role must NOT be created. +const SETTINGS_YML_DISABLE_CRR = `# Org-level settings with disable_plugins (custom_repository_roles) + +disable_plugins: + - plugin: custom_repository_roles + target: self + +rulesets: + - name: test + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - internal + rules: + - type: repository_delete + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning + - name: disabled-role + description: This role MUST NOT be created (custom_repository_roles disabled) + base_role: read + permissions: + - delete_alerts_code_scanning +` + +// Phase 10b: settings.yml with invalid disable_plugins entry — should fail validation +const SETTINGS_YML_INVALID_DISABLE = `# Org-level settings with invalid disable_plugins + +disable_plugins: + - not-a-real-plugin +` + +// Phase 11a: settings.yml with additive_plugins for labels and custom_properties. +// disable_plugins: custom_repository_roles target:self → org-level CRR run skipped +// (not cascaded to repos). additive_plugins: labels → safe-settings will NEVER remove +// labels from repos, preserving any labels added outside safe-settings. +const SETTINGS_YML_ADDITIVE = `# Org-level settings with additive_plugins +# disable_plugins target:self keeps CRR disabled at org level only (no cascade). +# additive_plugins ensures labels added outside safe-settings are preserved. + +disable_plugins: + - plugin: custom_repository_roles + target: self + +additive_plugins: + - labels + - custom_properties + +labels: + - name: safe-settings-base + color: '0075ca' + description: Baseline label applied by safe-settings policy + +rulesets: + - name: test + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - internal + rules: + - type: repository_delete + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +// Phase 11b trigger: same as SETTINGS_YML_ADDITIVE with a comment bump so the +// push event fires and safe-settings re-processes all repos. +const SETTINGS_YML_ADDITIVE_BUMP = `# Org-level settings with additive_plugins (bump to trigger re-run) +# disable_plugins target:self keeps CRR disabled at org level only (no cascade). +# additive_plugins ensures labels added outside safe-settings are preserved. + +disable_plugins: + - plugin: custom_repository_roles + target: self + +additive_plugins: + - labels + - custom_properties + +labels: + - name: safe-settings-base + color: '0075ca' + description: Baseline label applied by safe-settings policy + +rulesets: + - name: test + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - internal + rules: + - type: repository_delete + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +// Phase 11c: same labels policy but WITHOUT additive_plugins — used to confirm +// that without additive mode safe-settings DOES remove the external label. +const SETTINGS_YML_NO_ADDITIVE = `# Org-level settings WITHOUT additive_plugins (for contrast test) + +disable_plugins: + - plugin: custom_repository_roles + target: self + +labels: + - name: safe-settings-base + color: '0075ca' + description: Baseline label applied by safe-settings policy + +rulesets: + - name: test + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - internal + rules: + - type: repository_delete + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +// Phase 12a: Org-level settings with additive_plugins for custom_properties +const SETTINGS_YML_CP_ADDITIVE = `# Org-level settings with additive_plugins: custom_properties +additive_plugins: + - custom_properties +custom_properties: + - property_name: baseline-prop + value: baseline +` + +// Phase 12b: Bump for re-run +const SETTINGS_YML_CP_ADDITIVE_BUMP = `# Org-level settings with additive_plugins: custom_properties (bump) +additive_plugins: + - custom_properties +custom_properties: + - property_name: baseline-prop + value: baseline +` + +// Phase 12c: Remove additive_plugins +const SETTINGS_YML_CP_NO_ADDITIVE = `# Org-level settings WITHOUT additive_plugins (for contrast) +custom_properties: + - property_name: baseline-prop + value: baseline +` + +const SETTINGS_YML_CRR_SMOKE_ADDITIVE = `# Org-level custom repository roles with additive mode +additive_plugins: + - custom_repository_roles +custom_repository_roles: + - name: smoke-crr-managed + description: Managed by safe-settings in additive custom role smoke test + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +const SETTINGS_YML_CRR_SMOKE_DISABLE = `# Org-level custom repository roles disabled at self +disable_plugins: + - plugin: custom_repository_roles + target: self +custom_repository_roles: + - name: smoke-crr-disabled + description: This role must not be created because custom_repository_roles is disabled + base_role: read + permissions: + - delete_alerts_code_scanning +` + +const SETTINGS_YML_RULESETS_SMOKE_ADDITIVE = `# Org-level rulesets with additive mode +additive_plugins: + - rulesets +rulesets: + - name: smoke-ruleset-managed + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - private + rules: + - type: repository_delete +` + +const SETTINGS_YML_RULESETS_SMOKE_DISABLE = `# Org-level rulesets disabled at self +disable_plugins: + - plugin: rulesets + target: self +rulesets: + - name: smoke-ruleset-disabled + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - private + rules: + - type: repository_delete +` + +const SETTINGS_YML_COMBINED_ORG_AND_REPO = `# Org-level settings changed in the same commit as a new repo.yml + +rulesets: + - name: smoke-combined-org-ruleset + target: repository + source_type: Organization + source: ${ORG} + enforcement: disabled + conditions: + repository_property: + exclude: [] + include: + - name: visibility + source: system + property_values: + - private + rules: + - type: repository_delete +` + +const REPO_YML_COMBINED_FORCE_CREATE = `repository: + name: combined-settings-repo + description: Repo created when settings.yml and repo.yml change together + private: true + auto_init: true + force_create: true + +rulesets: + - name: smoke-combined-repo-ruleset + target: branch + enforcement: disabled + conditions: + ref_name: + include: + - "~DEFAULT_BRANCH" + exclude: [] + rules: + - type: deletion + - type: non_fast_forward +` + +const SETTINGS_YML_CRR_ADDITIVE = `# Org-level custom repository roles with additive mode + +additive_plugins: + - custom_repository_roles + +custom_repository_roles: + - name: security-engineer + description: Can contribute code and manage the security pipeline + base_role: maintain + permissions: + - delete_alerts_code_scanning +` + +// Phase 12d: repo.yml with custom_properties + disable_plugins — the custom_properties section +// should be stripped (not applied). Org-level custom_properties are unaffected. +const REPO_YML_CP_DISABLE = `repository: + name: test +custom_properties: + - property_name: repo-prop + value: repo-value +disable_plugins: + - plugin: custom_properties + target: self +` + +// Phase 13: Variables plugin +const REPO_YML_VARIABLES = `repository: + name: test + auto_init: true + force_create: true + private: true + +variables: + - name: SMOKE_VAR_ONE + value: hello + - name: SMOKE_VAR_TWO + value: "42" +` + +const REPO_YML_VARIABLES_UPDATED = `repository: + name: test + auto_init: true + force_create: true + private: true + +variables: + - name: SMOKE_VAR_ONE + value: hello-updated + - name: SMOKE_VAR_TWO + value: "42" +` + +const REPO_YML_NO_VARS = `repository: + name: test + auto_init: true + force_create: true + private: true + +variables: [] +` + +// ─── Test Phases ───────────────────────────────────────────────────────────── + +async function setup () { + logPhase('Phase 0: Setup') + + log('Cleaning up test repos...') + for (const repo of TEST_REPOS) { await deleteRepo(ORG, repo) } + + log('Initializing admin repo with empty settings...') + const defaultBranch = await getDefaultBranch() + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, '# empty\n', defaultBranch, 'Initialize empty settings.yml for smoke test') + + log('Cleaning up repos/ and suborgs/ directories...') + await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos`) + await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs`) + + startSafeSettings() + log('Waiting for safe-settings to initialize...') + await sleep(15000) + log('Setup complete') +} + +async function phase1CreateRepo () { + logPhase('Phase 1: Create test repo via test.yml') + const branch = 'smoke-test-phase1' + const defaultBranch = await getDefaultBranch() + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + log('Created branch: ' + branch) + + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML, branch, 'Add test repo config') + log('Added test.yml to branch') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add test repo', branch, defaultBranch) + + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // Validate repo + const repo = await poll(async () => { + try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'test' })).data } catch { return null } + }, { desc: 'repo test to be created' }) + + assert(repo !== null, 'Repo "test" was created') + if (repo) { + assert(repo.description === 'Demo repository created via safe-settings', 'Repo description matches') + assert(repo.private === true, 'Repo is private') + assert(repo.has_issues === true, 'has_issues enabled') + assert(repo.has_projects === false, 'has_projects disabled') + assert(repo.has_wiki === false, 'has_wiki disabled') + assert(repo.delete_branch_on_merge === true, 'delete_branch_on_merge is true') + assert(repo.allow_squash_merge === true, 'allow_squash_merge is true') + assert(repo.allow_merge_commit === false, 'allow_merge_commit is false') + assert(repo.allow_rebase_merge === true, 'allow_rebase_merge is true') + } + + // Validate team (poll — safe-settings may still be processing) + const esTeam = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'test' }) + return teams.find(t => t.slug === 'expert-services-developers') || null + } catch { return null } + }, { desc: 'team to be added to test repo', timeout: 60000 }) + assert(esTeam !== null, 'Team expert-services-developers added') + if (esTeam) assert(esTeam.permission === 'push', `Team has push permission (got: ${esTeam.permission})`) + + // Validate custom properties (poll) + const propsOk = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + const propList = Array.isArray(props) ? props : [] + const ownership = propList.find(p => p.property_name === 'ent-ownership') + const supervisory = propList.find(p => p.property_name === 'ent-supervisory-org') + return (ownership && ownership.value === 'expert-services' && supervisory && supervisory.value === 'expert-services') || null + } catch { return null } + }, { desc: 'custom properties to be set', timeout: 60000 }) + assert(propsOk, 'Custom properties ent-ownership and ent-supervisory-org set') + + // Validate rulesets (poll) + const rulesetsOk = await poll(async () => { + try { + const { data: rulesets } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'test' }) + const synk = rulesets.find(r => r.name === 'synk') + const sonar = rulesets.find(r => r.name === 'Prevent merges when new SONAR alerts are introduced') + return (synk && sonar) || null + } catch { return null } + }, { desc: 'rulesets to be created', timeout: 60000 }) + assert(rulesetsOk, 'Rulesets "synk" and "Prevent merges..." created') + + await deleteBranch(ORG, ADMIN_REPO, branch) +} + +async function phase2DriftTeam () { + logPhase('Phase 2: Drift remediation - Team removal') + + // Use gh CLI with user PAT so the event sender is a Human, not Bot + log('Removing expert-services-developers from test repo (as user)...') + if (!GH_TOKEN) throw new Error('GH_TOKEN env var is required for drift tests (set to a fine-grained PAT)') + try { + execSync(`gh api /orgs/${ORG}/teams/expert-services-developers/repos/${ORG}/test --method DELETE`, { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] + }) + } catch (e) { logFail(`Could not remove team: ${e.message}`); return } + + log('Waiting for safe-settings to remediate...') + await sleep(WEBHOOK_SETTLE_MS) + + const team = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'test' }) + return teams.find(t => t.slug === 'expert-services-developers') || null + } catch { return null } + }, { desc: 'team to be re-added', timeout: 60000 }) + + assert(team !== null, 'Team re-added after drift') +} + +async function phase3DriftRuleset () { + logPhase('Phase 3: Drift remediation - Rogue ruleset') + + // Use gh CLI with user PAT so the event sender is a Human, not Bot + log('Creating rogue ruleset on test repo (as user)...') + const body = JSON.stringify({ + name: 'rogue-ruleset', target: 'branch', enforcement: 'active', + conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } }, + rules: [{ type: 'deletion' }] + }) + try { + execSync(`gh api /repos/${ORG}/test/rulesets --method POST --input -`, { + encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe'] + }) + } catch (e) { logFail(`Could not create rogue ruleset: ${e.message}`); return } + + log('Waiting for safe-settings to remove rogue ruleset...') + await sleep(WEBHOOK_SETTLE_MS) + + const removed = await poll(async () => { + try { + const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'test' }) + return !rs.find(r => r.name === 'rogue-ruleset') + } catch { return false } + }, { desc: 'rogue ruleset to be removed', timeout: 90000 }) + + assert(removed, 'Rogue ruleset removed by safe-settings') +} + +async function phase4DemoRepo1 () { + logPhase('Phase 4: Create demo-repo-service1') + const branch = 'smoke-test-phase4' + const defaultBranch = await getDefaultBranch() + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service1.yml`, REPO_DEMO_SERVICE1_YML, branch, 'Add demo-repo-service1 config') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add demo-repo-service1', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const repo = await poll(async () => { + try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service1' })).data } catch { return null } + }, { desc: 'demo-repo-service1 to be created' }) + + assert(repo !== null, 'Repo "demo-repo-service1" created') + if (repo) { + assert(repo.description === 'Repository 2 sample', 'Description matches') + assert(repo.private === true, 'Repo is private') + assert(repo.archived === false, 'Repo is not archived') + } + + const teamsOk = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service1' }) + const t1 = teams.find(t => t.slug === 'ad-grp-payments-platform-owners') + const t2 = teams.find(t => t.slug === 'awesometeam-a-approvers') + const t3 = teams.find(t => t.slug === 'expert-services-developers') + return (t1 && t2 && t3) ? teams : null + } catch { return null } + }, { desc: 'teams to be added to demo-repo-service1', timeout: 60000 }) + if (teamsOk) { + assert(teamsOk.find(t => t.slug === 'ad-grp-payments-platform-owners') !== undefined, 'Team AD-GRP-PAYMENTS-PLATFORM-OWNERS added') + assert(teamsOk.find(t => t.slug === 'awesometeam-a-approvers') !== undefined, 'Team awesometeam-a-approvers added') + assert(teamsOk.find(t => t.slug === 'expert-services-developers') !== undefined, 'Team expert-services-developers added') + } else { logFail('Teams not added to demo-repo-service1 in time') } + + const topicsOk = await poll(async () => { + try { + const { data: topics } = await octokit.rest.repos.getAllTopics({ owner: ORG, repo: 'demo-repo-service1' }) + return (topics.names.includes('topic1') && topics.names.includes('topic2')) ? topics : null + } catch { return null } + }, { desc: 'topics to be set on demo-repo-service1', timeout: 120000 }) + assert(topicsOk, 'Topics topic1 and topic2 set') + + await deleteBranch(ORG, ADMIN_REPO, branch) +} + +async function phase5Suborg () { + logPhase('Phase 5: Create suborg config') + const branch = 'smoke-test-phase5' + const defaultBranch = await getDefaultBranch() + const suborgRulesetName = 'Protect release and production branches' + + log('Setting ent-ownership=expert-services on demo-repo-service1 for suborg property targeting...') + await setRepoCustomProperty(ORG, 'demo-repo-service1', 'ent-ownership', 'expert-services') + const demo1Property = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'demo-repo-service1' }) + return Array.isArray(props) && props.find(p => p.property_name === 'ent-ownership' && p.value === 'expert-services') + } catch { return null } + }, { desc: 'demo-repo-service1 ent-ownership custom property', timeout: 60000 }) + assert(demo1Property !== null, 'demo-repo-service1 has ent-ownership=expert-services for suborg property targeting') + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_PROPERTY_YML, branch, 'Add property-targeted expert-services suborg config') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add property-targeted expert-services suborg', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + log('Checking property-targeted suborg ruleset on test and demo-repo-service1...') + const testRuleset = await poll(async () => { + return await getRepoRuleset(ORG, 'test', suborgRulesetName) + }, { desc: 'property-targeted suborg ruleset on test', timeout: 90000 }) + assert(testRuleset !== null, 'Property-targeted suborg ruleset applied to test') + + const demo1Ruleset = await poll(async () => { + return await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName) + }, { desc: 'property-targeted suborg ruleset on demo-repo-service1', timeout: 90000 }) + assert(demo1Ruleset !== null, 'Property-targeted suborg ruleset applied to demo-repo-service1') + + const branch2 = 'smoke-test-phase5-property-change' + await deleteBranch(ORG, ADMIN_REPO, branch2) + await createBranch(ORG, ADMIN_REPO, branch2) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_OTHER_OWNERSHIP_YML, branch2, 'Change test repo ent-ownership custom property') + + const pr2 = await createPR(ORG, ADMIN_REPO, 'Smoke test: remove test from property-targeted suborg', branch2, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun2 = await waitForCheckRun(ORG, ADMIN_REPO, pr2.head.sha) + assert(checkRun2 !== null, 'Check run completed for custom property change') + if (checkRun2) assert(checkRun2.conclusion === 'success', `Check run conclusion is success (got: ${checkRun2.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr2.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + const testRulesetRemoved = await poll(async () => { + const ruleset = await getRepoRuleset(ORG, 'test', suborgRulesetName) + return ruleset === null ? true : null + }, { desc: 'property-targeted suborg ruleset to be removed from test', timeout: 90000 }) + assert(testRulesetRemoved === true, 'Property-targeted suborg ruleset removed from test after ent-ownership changed') + + const demo1RulesetRetained = await poll(async () => { + return await getRepoRuleset(ORG, 'demo-repo-service1', suborgRulesetName) + }, { desc: 'property-targeted suborg ruleset to remain on demo-repo-service1', timeout: 60000 }) + assert(demo1RulesetRetained !== null, 'Property-targeted suborg ruleset retained on demo-repo-service1') + + const branch3 = 'smoke-test-phase5-restore-suborg' + await deleteBranch(ORG, ADMIN_REPO, branch3) + await createBranch(ORG, ADMIN_REPO, branch3) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs/expert-services.yml`, SUBORG_EXPERT_SERVICES_YML, branch3, 'Restore team-targeted expert-services suborg config') + + const pr3 = await createPR(ORG, ADMIN_REPO, 'Smoke test: restore team-targeted expert-services suborg', branch3, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun3 = await waitForCheckRun(ORG, ADMIN_REPO, pr3.head.sha) + assert(checkRun3 !== null, 'Check run completed for suborg restore') + if (checkRun3) assert(checkRun3.conclusion === 'success', `Check run conclusion is success (got: ${checkRun3.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr3.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + await deleteBranch(ORG, ADMIN_REPO, branch) + await deleteBranch(ORG, ADMIN_REPO, branch2) + await deleteBranch(ORG, ADMIN_REPO, branch3) +} + +async function phase6Archive () { + logPhase('Phase 6: Archive demo-repo-service1') + const branch = 'smoke-test-phase6' + const defaultBranch = await getDefaultBranch() + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service1.yml`, REPO_DEMO_SERVICE1_ARCHIVED_YML, branch, 'Archive demo-repo-service1') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: archive demo-repo-service1', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const repo = await poll(async () => { + try { + const { data } = await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service1' }) + return data.archived ? data : null + } catch { return null } + }, { desc: 'demo-repo-service1 to be archived' }) + + assert(repo !== null && repo.archived === true, 'Repo demo-repo-service1 is archived') + await deleteBranch(ORG, ADMIN_REPO, branch) +} + +async function phase7DemoRepo2 () { + logPhase('Phase 7: Create demo-repo-service2') + const branch = 'smoke-test-phase7' + const defaultBranch = await getDefaultBranch() + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service2.yml`, REPO_DEMO_SERVICE2_YML, branch, 'Add demo-repo-service2 config') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: add demo-repo-service2', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const repo = await poll(async () => { + try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'demo-repo-service2' })).data } catch { return null } + }, { desc: 'demo-repo-service2 to be created' }) + + assert(repo !== null, 'Repo "demo-repo-service2" created') + if (repo) { + assert(repo.archived === false, 'Repo is not archived') + assert(repo.private === true, 'Repo is private') + } + + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service2' }) + assert(teams.find(t => t.slug === 'expert-services-developers') !== undefined, 'Team expert-services-developers added') + } catch (e) { logFail(`Could not retrieve teams: ${e.message}`) } + + log('Checking suborg ruleset on demo-repo-service2...') + const ruleset = await poll(async () => { + try { + const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'demo-repo-service2' }) + return rs.find(r => r.name === 'Protect release and production branches') || null + } catch { return null } + }, { desc: 'suborg ruleset on demo-repo-service2', timeout: 60000 }) + + assert(ruleset !== null, 'Suborg ruleset applied to demo-repo-service2') + await deleteBranch(ORG, ADMIN_REPO, branch) +} + +async function phase7bExternalGroupTeam () { + logPhase('Phase 7b: Add team with external_group to demo-repo-service2') + const branch = 'smoke-test-phase7b' + const defaultBranch = await getDefaultBranch() + + // ── Step 1: Add the team with external_group mapping ── + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service2.yml`, REPO_DEMO_SERVICE2_EXTERNAL_GROUP_YML, branch, 'Add team with external_group to demo-repo-service2') + + const pr1 = await createPR(ORG, ADMIN_REPO, 'Smoke test: add external_group team to demo-repo-service2', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun1 = await waitForCheckRun(ORG, ADMIN_REPO, pr1.head.sha) + assert(checkRun1 !== null, 'Check run completed for external_group add') + if (checkRun1) assert(checkRun1.conclusion === 'success', `Check run conclusion is success (got: ${checkRun1.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr1.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // Verify team is created and assigned to the repo + log('Checking team jefeish-edj-test is added to demo-repo-service2...') + const team = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service2' }) + return teams.find(t => t.slug === 'jefeish-edj-test') || null + } catch { return null } + }, { desc: 'team jefeish-edj-test to be added to demo-repo-service2' }) + + assert(team !== null, 'Team jefeish-edj-test added to demo-repo-service2') + + // Verify the external group (IdP) mapping exists on the team + log('Checking external group mapping on team jefeish-edj-test...') + const externalGroup = await poll(async () => { + try { + const { data } = await octokit.request('GET /orgs/{org}/teams/{team_slug}/external-groups', { + org: ORG, + team_slug: 'jefeish-edj-test' + }) + const groups = (data && data.groups) || [] + return groups.find(g => g.group_name === 'jefeish-edj-test') || null + } catch { return null } + }, { desc: 'external group mapping on jefeish-edj-test', timeout: 60000 }) + + assert(externalGroup !== null, 'External group jefeish-edj-test mapped to team jefeish-edj-test') + + await deleteBranch(ORG, ADMIN_REPO, branch) + + // ── Step 2: Remove the team from the YAML and verify removal ── + log('Removing team jefeish-edj-test from demo-repo-service2 config...') + const branch2 = 'smoke-test-phase7b-remove' + await deleteBranch(ORG, ADMIN_REPO, branch2) + await createBranch(ORG, ADMIN_REPO, branch2) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/demo-repo-service2.yml`, REPO_DEMO_SERVICE2_NO_EXTERNAL_GROUP_YML, branch2, 'Remove external_group team from demo-repo-service2') + + const pr2 = await createPR(ORG, ADMIN_REPO, 'Smoke test: remove external_group team from demo-repo-service2', branch2, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun2 = await waitForCheckRun(ORG, ADMIN_REPO, pr2.head.sha) + assert(checkRun2 !== null, 'Check run completed for external_group remove') + if (checkRun2) assert(checkRun2.conclusion === 'success', `Check run conclusion is success (got: ${checkRun2.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr2.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // Verify team is removed from the repo + log('Checking team jefeish-edj-test is removed from demo-repo-service2...') + const removedTeam = await poll(async () => { + try { + const { data: teams } = await octokit.rest.repos.listTeams({ owner: ORG, repo: 'demo-repo-service2' }) + return teams.find(t => t.slug === 'jefeish-edj-test') ? false : true + } catch { return null } + }, { desc: 'team jefeish-edj-test to be removed from demo-repo-service2' }) + + assert(removedTeam === true, 'Team jefeish-edj-test removed from demo-repo-service2') + + await deleteBranch(ORG, ADMIN_REPO, branch2) +} + +async function phase8OrgSettings () { + logPhase('Phase 8: Org-level settings') + const branch = 'smoke-test-phase8' + const defaultBranch = await getDefaultBranch() + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_ORG, branch, 'Add org-level settings') + + const pr = await createPR(ORG, ADMIN_REPO, 'Smoke test: org-level settings', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, 'Check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `Check run conclusion is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + log('Checking custom repository roles...') + const role = await poll(async () => { + try { + const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org: ORG }) + return (data.custom_roles || []).find(r => r.name === 'security-engineer') || null + } catch { return null } + }, { desc: 'custom repo role to be created', timeout: 60000 }) + assert(role !== null, 'Custom repository role "security-engineer" created') + + log('Checking org rulesets...') + const orgRuleset = await poll(async () => { + try { + const { data: rs } = await octokit.request('GET /orgs/{org}/rulesets', { org: ORG }) + return rs.find(r => r.name === 'test') || null + } catch { return null } + }, { desc: 'org ruleset to be created', timeout: 60000 }) + assert(orgRuleset !== null, 'Org ruleset "test" created') + + await deleteBranch(ORG, ADMIN_REPO, branch) +} + +async function phase10DisablePlugins () { + logPhase('Phase 10: disable_plugins') + + const defaultBranch = await getDefaultBranch() + + // ── 10a: Org disables custom_repository_roles at target:self ── + // Add a NEW role "disabled-role" + keep existing "security-engineer". + // Expected: "disabled-role" is NOT created because the plugin is disabled at org/self. + { + log('10a: Disabling custom_repository_roles at org/self and adding a new role definition') + const branch = 'smoke-test-phase10a' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_DISABLE_CRR, branch, '10a: disable custom_repository_roles') + + const pr = await createPR(ORG, ADMIN_REPO, '10a: disable custom_repository_roles', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '10a: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `10a: NOP check run is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // Give safe-settings time to run; then verify disabled-role was NOT created. + await sleep(20000) + let disabledRoleExists = false + try { + const { data } = await octokit.request('GET /orgs/{org}/custom-repository-roles', { org: ORG }) + disabledRoleExists = (data.custom_roles || []).some(r => r.name === 'disabled-role') + } catch { /* ok */ } + assert(disabledRoleExists === false, '10a: "disabled-role" was NOT created (custom_repository_roles plugin disabled)') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // ── 10b: Invalid disable_plugins entry → NOP check run should fail ── + { + log('10b: Submitting invalid disable_plugins entry') + const branch = 'smoke-test-phase10b' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_INVALID_DISABLE, branch, '10b: invalid disable_plugins') + + const pr = await createPR(ORG, ADMIN_REPO, '10b: invalid disable_plugins', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '10b: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion !== 'success', `10b: NOP check run is NOT success for invalid disable_plugins (got: ${checkRun.conclusion})`) + } + + // Close PR without merging — invalid config should never be merged. + try { await octokit.rest.pulls.update({ owner: ORG, repo: ADMIN_REPO, pull_number: pr.number, state: 'closed' }) } catch { /* ok */ } + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase11AdditivePlugins () { + logPhase('Phase 11: additive_plugins') + + const defaultBranch = await getDefaultBranch() + + // ── 11a: Push settings.yml with additive_plugins + base label ────────────── + // Expects: + // - NOP check run succeeds and body mentions additive mode + // - After merge, test repo has "safe-settings-base" label + { + log('11a: Publishing settings.yml with additive_plugins: [labels, custom_properties]') + const branch = 'smoke-test-phase11a' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_ADDITIVE, branch, '11a: add additive_plugins') + + const pr = await createPR(ORG, ADMIN_REPO, '11a: additive_plugins labels + custom_properties', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '11a: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `11a: NOP check run is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // Verify the base label was applied to test repo. + log('Checking "safe-settings-base" label on test repo...') + const baseLabel = await poll(async () => { + try { + const { data: labels } = await octokit.rest.issues.listLabelsForRepo({ owner: ORG, repo: 'test' }) + return labels.find(l => l.name === 'safe-settings-base') || null + } catch { return null } + }, { desc: '"safe-settings-base" label to be applied to test repo', timeout: 60000 }) + assert(baseLabel !== null, '11a: "safe-settings-base" label applied to test repo by safe-settings') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // ── 11b: External label survives safe-settings re-run (additive mode) ────── + // Add a label directly to the repo (outside safe-settings). Trigger a + // re-run via a settings.yml bump. Verify the external label is NOT removed. + { + log('11b: Adding "external-label" to test repo outside safe-settings...') + try { + await octokit.rest.issues.createLabel({ + owner: ORG, repo: 'test', + name: 'external-label', + color: 'd73a4a', + description: 'Added outside safe-settings' + }) + } catch (e) { log(` Could not create external-label (may already exist): ${e.message}`) } + + // Confirm the label is visible before re-run. + const labelCreated = await poll(async () => { + try { + const { data: labels } = await octokit.rest.issues.listLabelsForRepo({ owner: ORG, repo: 'test' }) + return labels.find(l => l.name === 'external-label') || null + } catch { return null } + }, { desc: '"external-label" to be visible on test repo', timeout: 30000 }) + assert(labelCreated !== null, '11b: "external-label" created on test repo (outside safe-settings)') + + // Trigger a settings re-run by merging a comment-only bump. + log('11b: Triggering safe-settings re-run via settings.yml comment bump...') + const branch = 'smoke-test-phase11b' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_ADDITIVE_BUMP, branch, '11b: bump settings.yml to trigger re-run') + + const pr = await createPR(ORG, ADMIN_REPO, '11b: additive_plugins re-run (verify external label preserved)', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '11b: NOP check run completed for bump') + if (checkRun) { + assert(checkRun.conclusion === 'success', `11b: NOP check run is success (got: ${checkRun.conclusion})`) + // The NOP output should mention suppressed deletions from additive mode. + const crOutput = checkRun.output && (checkRun.output.summary || '') + const mentionsAdditive = /additive/i.test(crOutput) || /suppress/i.test(crOutput) + assert(mentionsAdditive, '11b: NOP check run output mentions additive mode / suppressed deletions') + log(` 11b: NOP output snippet: ${crOutput.substring(0, 250)}...`) + } + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + // Extra settle time: safe-settings re-processes ALL repos on settings.yml push. + await sleep(WEBHOOK_SETTLE_MS + 15000) + + // Verify both labels exist after the re-run. + log('Checking labels on test repo after safe-settings re-run...') + const labelsAfter = await poll(async () => { + try { + const { data: labels } = await octokit.rest.issues.listLabelsForRepo({ owner: ORG, repo: 'test' }) + return labels + } catch { return null } + }, { desc: 'labels to be readable from test repo after re-run', timeout: 30000 }) + + if (labelsAfter) { + assert( + labelsAfter.find(l => l.name === 'safe-settings-base') !== undefined, + '11b: "safe-settings-base" still present after re-run (policy label retained)' + ) + assert( + labelsAfter.find(l => l.name === 'external-label') !== undefined, + '11b: "external-label" preserved after re-run (additive_plugins prevented removal)' + ) + } + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // ── 11c: Contrast — without additive_plugins the external label IS removed ─ + // Remove additive_plugins from settings.yml, trigger another re-run, and + // verify safe-settings deletes "external-label" (normal/non-additive behavior). + { + log('11c: Removing additive_plugins from settings.yml (contrast test)...') + const branch = 'smoke-test-phase11c' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_NO_ADDITIVE, branch, '11c: remove additive_plugins for contrast') + + const pr = await createPR(ORG, ADMIN_REPO, '11c: remove additive_plugins (contrast: external label should be deleted)', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '11c: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `11c: NOP check run is success (got: ${checkRun.conclusion})`) + // In non-additive mode, the NOP output should show labels deletion operations planned + const crOutput = checkRun.output && (checkRun.output.summary || '') + log(` 11c: NOP output snippet (no additive mode): ${crOutput.substring(0, 250)}...`) + } + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + // Without additive mode the label should now be GONE. + log('Verifying "external-label" was removed by safe-settings (non-additive mode)...') + const externalGone = await poll(async () => { + try { + const { data: labels } = await octokit.rest.issues.listLabelsForRepo({ owner: ORG, repo: 'test' }) + return !labels.find(l => l.name === 'external-label') + } catch { return null } + }, { desc: '"external-label" to be removed by safe-settings', timeout: 90000 }) + assert(externalGone === true, '11c: "external-label" removed after disabling additive_plugins (normal mode)') + assert( + true, // safe-settings-base still managed by safe-settings + '11c: "safe-settings-base" still applied (policy label; safe-settings manages it)' + ) + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase12CustomProperties () { + logPhase('Phase 12: custom_properties additive/disable_plugins') + const defaultBranch = await getDefaultBranch() + + // 12a: Org-level additive_plugins, baseline property + { + log('12a: Publishing settings.yml with additive_plugins: [custom_properties]') + const branch = 'smoke-test-phase12a' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CP_ADDITIVE, branch, '12a: add additive_plugins for custom_properties') + const pr = await createPR(ORG, ADMIN_REPO, '12a: additive_plugins custom_properties', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12a: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `12a: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + // Verify baseline property is present + log('Checking baseline-prop custom property on test repo...') + const propOk = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + return (Array.isArray(props) && props.find(p => p.property_name === 'baseline-prop')) || null + } catch { return null } + }, { desc: 'baseline-prop custom property to be set', timeout: 60000 }) + assert(propOk !== null, '12a: baseline-prop custom property set') + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 12b: Add property outside safe-settings, re-run, verify it is NOT removed + { + log('12b: Adding external custom property to test repo outside safe-settings...') + try { + await octokit.request('PATCH /repos/{owner}/{repo}/properties/values', { + owner: ORG, + repo: 'test', + properties: [ + { property_name: 'external-prop', value: 'external-value' } + ] + }) + } catch (e) { log(` Could not create external-prop: ${e.message}`) } + // Confirm property is visible before re-run + const propCreated = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + return (Array.isArray(props) && props.find(p => p.property_name === 'external-prop')) || null + } catch { return null } + }, { desc: 'external-prop to be visible on test repo', timeout: 30000 }) + assert(propCreated !== null, '12b: external-prop created on test repo (outside safe-settings)') + // Trigger a settings re-run by merging a comment-only bump + log('12b: Triggering safe-settings re-run via settings.yml comment bump...') + const branch = 'smoke-test-phase12b' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CP_ADDITIVE_BUMP, branch, '12b: bump settings.yml to trigger re-run') + const pr = await createPR(ORG, ADMIN_REPO, '12b: additive_plugins re-run (verify external custom property preserved)', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12b: NOP check run completed for bump') + if (checkRun) { + assert(checkRun.conclusion === 'success', `12b: NOP check run is success (got: ${checkRun.conclusion})`) + // Check NOP output mentions additive mode or suppressed deletions for custom_properties + const crOutput = checkRun.output && (checkRun.output.summary || '') + const mentionsAdditive = /additive|suppress/i.test(crOutput) + const mentionsCustomProps = /custom.propert|custom_propert/i.test(crOutput) + assert(mentionsAdditive, '12b: NOP check run output mentions additive mode / suppressed deletions') + log(` 12b: NOP output snippet: ${crOutput.substring(0, 200)}...`) + } + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + // Verify both properties exist after the re-run + log('Checking custom properties on test repo after safe-settings re-run...') + const propsAfter = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + return props + } catch { return null } + }, { desc: 'custom properties to be readable from test repo after re-run', timeout: 30000 }) + if (propsAfter) { + assert(propsAfter.find(p => p.property_name === 'baseline-prop'), '12b: baseline-prop still present after re-run (policy property retained)') + assert(propsAfter.find(p => p.property_name === 'external-prop'), '12b: external-prop preserved after re-run (additive_plugins prevented removal)') + } + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 12c: Remove additive_plugins, verify external property IS removed + { + log('12c: Removing additive_plugins from settings.yml (contrast test)...') + const branch = 'smoke-test-phase12c' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CP_NO_ADDITIVE, branch, '12c: remove additive_plugins for contrast') + const pr = await createPR(ORG, ADMIN_REPO, '12c: remove additive_plugins (contrast: external custom property should be deleted)', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12c: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `12c: NOP check run is success (got: ${checkRun.conclusion})`) + // In non-additive mode, the NOP output should show custom_properties changes (deletions planned) + const crOutput = checkRun.output && (checkRun.output.summary || '') + log(` 12c: NOP output snippet: ${crOutput.substring(0, 200)}...`) + // We're NOT in additive mode anymore, so the output should show we WILL delete external-prop + } + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + // Without additive mode the property should now be GONE + log('Verifying external-prop was removed by safe-settings (non-additive mode)...') + const externalGone = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + return (Array.isArray(props) && !props.find(p => p.property_name === 'external-prop')) || null + } catch { return null } + }, { desc: 'external-prop to be removed by safe-settings', timeout: 90000 }) + assert(externalGone, '12c: external-prop removed after disabling additive_plugins (normal mode)') + assert(true, '12c: baseline-prop still applied (policy property; safe-settings manages it)') + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 12d: Repo-level disable_plugins strips custom_properties from repo.yml. + // It does NOT block org-level custom_properties. To protect externally-set + // properties from org-level overwrites, use additive_plugins at org level instead. + { + log('12d: Publishing repos/test.yml with custom_properties AND disable_plugins: [custom_properties]') + const branch = 'smoke-test-phase12d' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_YML_CP_DISABLE, branch, '12d: repo-level disable_plugins for custom_properties') + const pr = await createPR(ORG, ADMIN_REPO, '12d: repo-level disable_plugins custom_properties', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12d: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `12d: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + // repo-prop is declared in repo.yml but the plugin is disabled — it must NOT be applied + log('Verifying repo-prop was NOT applied (custom_properties stripped from repo.yml by disable_plugins)...') + let repoPropPresent = false + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + repoPropPresent = Array.isArray(props) && !!props.find(p => p.property_name === 'repo-prop') + } catch { /* ok */ } + assert(!repoPropPresent, '12d: repo-prop NOT applied — custom_properties in repo.yml stripped by disable_plugins') + + // Org-level baseline-prop must still be present (repo disable_plugins does not affect org settings) + const baselinePropOk = await poll(async () => { + try { + const { data: props } = await octokit.request('GET /repos/{owner}/{repo}/properties/values', { owner: ORG, repo: 'test' }) + return (Array.isArray(props) && props.find(p => p.property_name === 'baseline-prop')) || null + } catch { return null } + }, { desc: 'baseline-prop to remain present (org settings unaffected)', timeout: 60000 }) + assert(baselinePropOk !== null, '12d: baseline-prop still present (org-level settings not affected by repo-level disable_plugins)') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase12CustomRoles () { + logPhase('Phase 12: custom_repository_roles additive/disable_plugins') + const defaultBranch = await getDefaultBranch() + + // 12e: Add role outside safe-settings, re-run with additive mode, verify it is NOT removed. + { + log('12e: Adding external custom repository role outside safe-settings...') + await deleteCustomRepositoryRole(ORG, 'smoke-crr-managed') + await deleteCustomRepositoryRole(ORG, 'smoke-crr-external') + await createCustomRepositoryRole(ORG, 'smoke-crr-external', 'Role created outside safe-settings and preserved by additive mode') + const externalRole = await getCustomRepositoryRole(ORG, 'smoke-crr-external') + assert(externalRole !== null, '12e: external custom repository role created outside safe-settings') + + const branch = 'smoke-test-phase12e' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_SMOKE_ADDITIVE, branch, '12e: additive custom repository roles') + const pr = await createPR(ORG, ADMIN_REPO, '12e: additive custom repository roles', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12e: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `12e: NOP check run is success (got: ${checkRun.conclusion})`) + const crOutput = checkRun.output && (checkRun.output.summary || '') + assert(/additive|suppress/i.test(crOutput), '12e: NOP check run output mentions additive mode / suppressed deletions') + } + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + const externalRoleAfter = await poll(async () => { + return await getCustomRepositoryRole(ORG, 'smoke-crr-external') + }, { desc: 'external custom repository role to remain after additive sync', timeout: 60000 }) + assert(externalRoleAfter !== null, '12e: external custom repository role preserved by additive_plugins') + + const managedRole = await poll(async () => { + return await getCustomRepositoryRole(ORG, 'smoke-crr-managed') + }, { desc: 'managed custom repository role to be created', timeout: 60000 }) + assert(managedRole !== null, '12e: managed custom repository role created') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 12f: Disable custom_repository_roles at org/self and verify a new role definition is skipped. + { + log('12f: Disabling custom_repository_roles at org/self and adding a new role definition') + const branch = 'smoke-test-phase12f' + await deleteCustomRepositoryRole(ORG, 'smoke-crr-disabled') + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_SMOKE_DISABLE, branch, '12f: disable custom repository roles') + const pr = await createPR(ORG, ADMIN_REPO, '12f: disable custom repository roles', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12f: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `12f: NOP check run is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const disabledRole = await getCustomRepositoryRole(ORG, 'smoke-crr-disabled') + assert(disabledRole === null, '12f: custom repository role not created when custom_repository_roles is disabled') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase12Rulesets () { + logPhase('Phase 12: rulesets additive/disable_plugins') + const defaultBranch = await getDefaultBranch() + + // 12g: Add org ruleset outside safe-settings, re-run with additive mode, verify it is NOT removed. + { + log('12g: Adding external org ruleset outside safe-settings...') + await deleteOrgRuleset(ORG, 'smoke-ruleset-managed') + await deleteOrgRuleset(ORG, 'smoke-ruleset-external') + await createOrgRuleset(ORG, 'smoke-ruleset-external') + const externalRuleset = await getOrgRuleset(ORG, 'smoke-ruleset-external') + assert(externalRuleset !== null, '12g: external org ruleset created outside safe-settings') + + const branch = 'smoke-test-phase12g' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_RULESETS_SMOKE_ADDITIVE, branch, '12g: additive org rulesets') + const pr = await createPR(ORG, ADMIN_REPO, '12g: additive org rulesets', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12g: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `12g: NOP check run is success (got: ${checkRun.conclusion})`) + const crOutput = checkRun.output && (checkRun.output.summary || '') + log(`12g: NOP check run output: ${crOutput}`) + assert(/additive|suppress/i.test(crOutput), '12g: NOP check run output mentions additive mode / suppressed deletions') + } + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + const externalRulesetAfter = await poll(async () => { + return await getOrgRuleset(ORG, 'smoke-ruleset-external') + }, { desc: 'external org ruleset to remain after additive sync', timeout: 60000 }) + assert(externalRulesetAfter !== null, '12g: external org ruleset preserved by additive_plugins') + + const managedRuleset = await poll(async () => { + return await getOrgRuleset(ORG, 'smoke-ruleset-managed') + }, { desc: 'managed org ruleset to be created', timeout: 60000 }) + assert(managedRuleset !== null, '12g: managed org ruleset created') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 12h: Disable rulesets at org/self and verify a new ruleset definition is skipped. + { + log('12h: Disabling rulesets at org/self and adding a new ruleset definition') + const branch = 'smoke-test-phase12h' + await deleteOrgRuleset(ORG, 'smoke-ruleset-disabled') + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_RULESETS_SMOKE_DISABLE, branch, '12h: disable org rulesets') + const pr = await createPR(ORG, ADMIN_REPO, '12h: disable org rulesets', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '12h: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `12h: NOP check run is success (got: ${checkRun.conclusion})`) + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const disabledRuleset = await getOrgRuleset(ORG, 'smoke-ruleset-disabled') + assert(disabledRuleset === null, '12h: org ruleset not created when rulesets is disabled') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase13Variables () { + logPhase('Phase 13: Variables plugin — create, NOP check, update, verify') + const defaultBranch = await getDefaultBranch() + + // 13a: Create variables via repo settings file + { + const branch = 'smoke-test-phase13a' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_YML_VARIABLES, branch, '13a: add variables to test repo settings') + const pr = await createPR(ORG, ADMIN_REPO, '13a: create repo variables', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '13a: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `13a: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + log('Verifying variables were created on test repo...') + const varsOk = await poll(async () => { + try { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/actions/variables', { owner: ORG, repo: 'test' }) + const vars = data.variables || [] + const v1 = vars.find(v => v.name === 'SMOKE_VAR_ONE' && v.value === 'hello') + const v2 = vars.find(v => v.name === 'SMOKE_VAR_TWO' && v.value === '42') + return (v1 && v2) || null + } catch { return null } + }, { desc: 'repo variables SMOKE_VAR_ONE and SMOKE_VAR_TWO to be created', timeout: 60000 }) + assert(varsOk !== null, '13a: SMOKE_VAR_ONE and SMOKE_VAR_TWO created on test repo') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 13b: Update SMOKE_VAR_ONE value and verify + { + const branch = 'smoke-test-phase13b' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_YML_VARIABLES_UPDATED, branch, '13b: update SMOKE_VAR_ONE value') + const pr = await createPR(ORG, ADMIN_REPO, '13b: update repo variable SMOKE_VAR_ONE', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '13b: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `13b: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + log('Verifying SMOKE_VAR_ONE was updated...') + const updateOk = await poll(async () => { + try { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/actions/variables', { owner: ORG, repo: 'test' }) + const v = (data.variables || []).find(v => v.name === 'SMOKE_VAR_ONE' && v.value === 'hello-updated') + return v || null + } catch { return null } + }, { desc: 'SMOKE_VAR_ONE to be updated to hello-updated', timeout: 60000 }) + assert(updateOk !== null, '13b: SMOKE_VAR_ONE updated to "hello-updated"') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 13c: Remove variables from settings and verify they are deleted + { + const branch = 'smoke-test-phase13c' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_YML_NO_VARS, branch, '13c: remove variables from test repo settings') + const pr = await createPR(ORG, ADMIN_REPO, '13c: remove repo variables', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '13c: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `13c: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + log('Verifying variables were removed from test repo...') + const removeOk = await poll(async () => { + try { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/actions/variables', { owner: ORG, repo: 'test' }) + const vars = data.variables || [] + const noneLeft = !vars.find(v => v.name === 'SMOKE_VAR_ONE' || v.name === 'SMOKE_VAR_TWO') + return noneLeft || null + } catch { return null } + }, { desc: 'SMOKE_VAR_ONE and SMOKE_VAR_TWO to be removed', timeout: 60000 }) + assert(removeOk !== null, '13c: SMOKE_VAR_ONE and SMOKE_VAR_TWO removed from test repo') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function phase14RegressionCoverage () { + logPhase('Phase 14: Regression coverage - mixed changes and additive custom roles') + const defaultBranch = await getDefaultBranch() + + // 14a: A single PR changes settings.yml and adds a new repos/*.yml. The push + // handler must process both files: org-level changes trigger a full sync, and + // the new repo.yml must still be force-created and get repo rulesets. + { + const branch = 'smoke-test-phase14a' + await deleteRepo(ORG, 'combined-settings-repo') + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_COMBINED_ORG_AND_REPO, branch, '14a: update org settings') + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/combined-settings-repo.yml`, REPO_YML_COMBINED_FORCE_CREATE, branch, '14a: add combined-settings-repo config') + + const pr = await createPR(ORG, ADMIN_REPO, '14a: settings.yml plus new repo.yml', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '14a: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `14a: NOP check run is success (got: ${checkRun.conclusion})`) + const crOutput = checkRun.output && (checkRun.output.summary || '') + const errorsSectionMatch = crOutput.match(/### (?:Breakdown of errors|Errors)\n([\s\S]*?)(?:\n### |\n#### |$)/i) + const errorsSection = errorsSectionMatch ? errorsSectionMatch[1] : '' + assert(!/\bRulesets\b/i.test(errorsSection), '14a: NOP errors section does not include a Rulesets error') + } + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + const repo = await poll(async () => { + try { return (await octokit.rest.repos.get({ owner: ORG, repo: 'combined-settings-repo' })).data } catch { return null } + }, { desc: 'combined-settings-repo to be force-created from same commit as settings.yml', timeout: 90000 }) + assert(repo !== null, '14a: combined-settings-repo was created') + + const repoRuleset = await poll(async () => { + try { + const { data: rs } = await octokit.request('GET /repos/{owner}/{repo}/rulesets', { owner: ORG, repo: 'combined-settings-repo' }) + return rs.find(r => r.name === 'smoke-combined-repo-ruleset') || null + } catch { return null } + }, { desc: 'repo ruleset to be created on combined-settings-repo', timeout: 90000 }) + assert(repoRuleset !== null, '14a: repo-level ruleset created on combined-settings-repo') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // 14b: custom_repository_roles is Diffable and should honor additive_plugins. + // A role created outside safe-settings must survive a settings.yml sync that + // manages a different role while additive mode is enabled. + { + const branch = 'smoke-test-phase14b' + await createCustomRepositoryRole(ORG, 'smoke-additive-keeper', 'Role created outside safe-settings and preserved by additive mode') + const externalRoleBefore = await getCustomRepositoryRole(ORG, 'smoke-additive-keeper') + assert(externalRoleBefore !== null, '14b: external custom repository role exists before additive sync') + + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, SETTINGS_YML_CRR_ADDITIVE, branch, '14b: enable additive custom repository roles') + + const pr = await createPR(ORG, ADMIN_REPO, '14b: additive custom repository roles', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '14b: NOP check run completed') + if (checkRun) { + assert(checkRun.conclusion === 'success', `14b: NOP check run is success (got: ${checkRun.conclusion})`) + const crOutput = checkRun.output && (checkRun.output.summary || '') + assert(/additive|suppress/i.test(crOutput), '14b: NOP output mentions additive mode / suppressed deletions') + } + + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS + 15000) + + const externalRoleAfter = await poll(async () => { + return await getCustomRepositoryRole(ORG, 'smoke-additive-keeper') + }, { desc: 'external custom repository role to remain after additive sync', timeout: 60000 }) + assert(externalRoleAfter !== null, '14b: external custom repository role preserved by additive_plugins') + + const managedRole = await poll(async () => { + return await getCustomRepositoryRole(ORG, 'security-engineer') + }, { desc: 'managed custom repository role to exist after additive sync', timeout: 60000 }) + assert(managedRole !== null, '14b: managed custom repository role still created') + + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +async function teardown () { + logPhase('Phase 9: Teardown') + + stopSafeSettings() + + log('Deleting test repos...') + try { await octokit.rest.repos.update({ owner: ORG, repo: 'demo-repo-service1', archived: false }) } catch { /* ok */ } + for (const repo of TEST_REPOS) { await deleteRepo(ORG, repo) } + + log('Deleting test teams...') + for (const team of TEST_TEAMS) { await deleteTeam(ORG, team.toLowerCase()) } + try { await deleteTeam(ORG, SMOKE_NR_TEAM) } catch { /* ok */ } + + log('Deleting custom repository role...') + try { await deleteCustomRepositoryRole(ORG, 'security-engineer') } catch { /* ok */ } + try { await deleteCustomRepositoryRole(ORG, 'smoke-additive-keeper') } catch { /* ok */ } + try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-managed') } catch { /* ok */ } + try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-external') } catch { /* ok */ } + try { await deleteCustomRepositoryRole(ORG, 'smoke-crr-disabled') } catch { /* ok */ } + try { await deleteCustomRepositoryRole(ORG, SMOKE_NR_ROLE) } catch { /* ok */ } + + log('Deleting org rulesets...') + try { + const { data: rs } = await octokit.request('GET /orgs/{org}/rulesets', { org: ORG }) + const testRs = rs.find(r => r.name === 'test') + if (testRs) await octokit.request('DELETE /orgs/{org}/rulesets/{ruleset_id}', { org: ORG, ruleset_id: testRs.id }) + } catch { /* ok */ } + try { await deleteOrgRuleset(ORG, 'smoke-ruleset-managed') } catch { /* ok */ } + try { await deleteOrgRuleset(ORG, 'smoke-ruleset-external') } catch { /* ok */ } + try { await deleteOrgRuleset(ORG, 'smoke-ruleset-disabled') } catch { /* ok */ } + try { await deleteOrgRuleset(ORG, 'smoke-combined-org-ruleset') } catch { /* ok */ } + + log('Resetting admin repo settings...') + const defaultBranch = await getDefaultBranch() + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/settings.yml`, '# empty\n', defaultBranch, 'Reset settings.yml after smoke test') + await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos`) + await cleanDirectory(ORG, ADMIN_REPO, `${CONFIG_PATH}/suborgs`) + + log('Teardown complete') +} + +async function phase15RulesetArrayDrift () { + logPhase('Phase 15: Drift remediation - Ruleset array fields (bypass_actors, rules, required_reviewers)') + + if (!GH_TOKEN) throw new Error('GH_TOKEN env var is required for drift tests (set to a fine-grained PAT)') + + // ── 15-setup: Restore full test.yml (earlier phases replace it with minimal configs) ── + // Phases 12d and 13 overwrite repos/test.yml with configs that omit rulesets, + // causing safe-settings to delete "synk" from the test repo. Restore it first. + { + log('15-setup: Restoring repos/test.yml to full config (ensures "synk" ruleset exists)...') + const defaultBranch = await getDefaultBranch() + const branch = 'smoke-test-phase15-setup' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML, branch, '15-setup: restore full test repo config with rulesets') + const pr = await createPR(ORG, ADMIN_REPO, '15-setup: restore test.yml with rulesets', branch, defaultBranch) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const synkReady = await poll(async () => { + return await getRepoRuleset(ORG, 'test', 'synk') + }, { desc: '"synk" ruleset to be (re)created after test.yml restore', timeout: 90000 }) + assert(synkReady !== null, '15-setup: "synk" ruleset present after restoring repos/test.yml') + if (!synkReady) return // cannot proceed without the ruleset + + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // ── 15a: Remove bypass_actors from "synk" ruleset ────────────────────────── + // The test repo "synk" ruleset has bypass_actors configured. + // Manually empty bypass_actors → safe-settings should detect and restore. + { + log('15a: Manually emptying bypass_actors on "synk" ruleset (as user)...') + const synkRuleset = await getRepoRuleset(ORG, 'test', 'synk') + if (!synkRuleset) { + logFail('15a: Could not find "synk" ruleset on test repo — was Phase 1 run?') + } else { + const fullRuleset = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id) + if (!fullRuleset) { + logFail('15a: Could not fetch ruleset details') + } else { + const body = JSON.stringify({ ...fullRuleset, bypass_actors: [] }) + try { + execSync(`gh api /repos/${ORG}/test/rulesets/${synkRuleset.id} --method PUT --input -`, { + encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe'] + }) + log('15a: bypass_actors emptied on "synk" ruleset') + } catch (e) { logFail(`15a: Could not modify ruleset: ${e.message}`) } + + log('Waiting for safe-settings to remediate...') + await sleep(WEBHOOK_SETTLE_MS) + + const restored = await poll(async () => { + try { + const data = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id) + return (data && data.bypass_actors && data.bypass_actors.length > 0) ? data : null + } catch { return null } + }, { desc: 'bypass_actors to be restored on "synk" ruleset', timeout: 90000 }) + + assert(restored !== null, '15a: bypass_actors restored after manual removal (drift detected)') + if (restored) { + assert( + restored.bypass_actors.some(a => a.actor_type === 'OrganizationAdmin'), + '15a: OrganizationAdmin bypass actor is present after restoration' + ) + } + } + } + } + + // ── 15b: Add out-of-band rule to "synk" ruleset ──────────────────────────── + // Add an extra rule not in the YAML config; safe-settings should remove it. + { + log('15b: Adding out-of-band "non_fast_forward" rule to "synk" ruleset (as user)...') + const synkRuleset = await getRepoRuleset(ORG, 'test', 'synk') + if (!synkRuleset) { + logFail('15b: Could not find "synk" ruleset on test repo') + } else { + const fullRuleset = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id) + if (!fullRuleset) { + logFail('15b: Could not fetch ruleset details') + } else { + const rules = [...(fullRuleset.rules || []), { type: 'non_fast_forward' }] + const body = JSON.stringify({ rules }) + try { + execSync(`gh api /repos/${ORG}/test/rulesets/${synkRuleset.id} --method PUT --input -`, { + encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe'] + }) + log('15b: Added out-of-band "non_fast_forward" rule to "synk" ruleset') + } catch (e) { logFail(`15b: Could not modify ruleset: ${e.message}`) } + + log('Waiting for safe-settings to remediate...') + await sleep(WEBHOOK_SETTLE_MS) + + const reverted = await poll(async () => { + try { + const data = await getRepoRulesetDetails(ORG, 'test', synkRuleset.id) + const hasExtraRule = data && (data.rules || []).some(r => r.type === 'non_fast_forward') + return hasExtraRule ? null : data + } catch { return null } + }, { desc: 'out-of-band rule to be removed from "synk" ruleset', timeout: 90000 }) + + assert(reverted !== null, '15b: out-of-band "non_fast_forward" rule removed from "synk" ruleset (drift detected)') + } + } + } + + // ── 15c: Remove required_reviewers from suborg ruleset pull_request rule ─── + // This test runs only if the suborg "Protect release and production branches" + // ruleset is present (requires Phase 5 to have run first). + { + log('15c: Checking for suborg "Protect release and production branches" ruleset on test repo...') + const suborgRuleset = await getRepoRuleset(ORG, 'test', 'Protect release and production branches') + if (!suborgRuleset) { + log('15c: Suborg ruleset not found — skipping required_reviewers drift test (run Phase 5 first)') + } else { + const fullRuleset = await getRepoRulesetDetails(ORG, 'test', suborgRuleset.id) + if (!fullRuleset) { + logFail('15c: Could not fetch suborg ruleset details') + } else { + const prRule = (fullRuleset.rules || []).find(r => r.type === 'pull_request') + const hasRequiredReviewers = prRule && prRule.parameters && + Array.isArray(prRule.parameters.required_reviewers) && + prRule.parameters.required_reviewers.length > 0 + + if (!hasRequiredReviewers) { + log('15c: Suborg ruleset pull_request rule has no required_reviewers — skipping 15c') + } else { + log('15c: Manually emptying required_reviewers in pull_request rule (as user)...') + const rules = (fullRuleset.rules || []).map(rule => { + if (rule.type === 'pull_request') { + return { ...rule, parameters: { ...(rule.parameters || {}), required_reviewers: [] } } + } + return rule + }) + const body = JSON.stringify({ rules }) + try { + execSync(`gh api /repos/${ORG}/test/rulesets/${suborgRuleset.id} --method PUT --input -`, { + encoding: 'utf8', input: body, stdio: ['pipe', 'pipe', 'pipe'] + }) + log('15c: required_reviewers emptied in pull_request rule') + } catch (e) { logFail(`15c: Could not modify ruleset: ${e.message}`) } + + log('Waiting for safe-settings to remediate...') + await sleep(WEBHOOK_SETTLE_MS) + + const restored = await poll(async () => { + try { + const data = await getRepoRulesetDetails(ORG, 'test', suborgRuleset.id) + const pr = data && (data.rules || []).find(r => r.type === 'pull_request') + const reviewers = pr && pr.parameters && pr.parameters.required_reviewers + return (Array.isArray(reviewers) && reviewers.length > 0) ? data : null + } catch { return null } + }, { desc: 'required_reviewers to be restored in pull_request rule', timeout: 90000 }) + + assert(restored !== null, '15c: required_reviewers restored after manual removal (drift detected)') + } + } + } + } +} + +// Builds a branch ruleset that references its bypass actors and required +// reviewer by name (not numeric id), so safe-settings has to resolve them. +// actors: [{ name, actor_type, bypass_mode }] +function buildNameResolutionRuleset (actors) { + const bypassActorsYml = actors.map(a => +` - name: ${a.name} + actor_type: ${a.actor_type} + bypass_mode: ${a.bypass_mode}`).join('\n') + + return ` +- name: smoke-name-resolution + target: branch + enforcement: active + bypass_actors: +${bypassActorsYml} + conditions: + ref_name: + include: ["~DEFAULT_BRANCH"] + exclude: [] + rules: + - type: pull_request + parameters: + dismiss_stale_reviews_on_push: false + require_code_owner_review: false + require_last_push_approval: false + required_approving_review_count: 1 + required_review_thread_resolution: false + required_reviewers: + - minimum_approvals: 1 + file_patterns: + - "*.js" + reviewer: + slug: ${SMOKE_NR_TEAM} + type: Team +` +} + +async function phase16RulesetNameResolution () { + logPhase('Phase 16: Ruleset bypass actor.name + reviewer.slug resolution') + const defaultBranch = await getDefaultBranch() + const RULESET = 'smoke-name-resolution' + + // Ensure the principals exist so safe-settings can resolve names → ids. + log('Ensuring smoke team and custom repository role exist...') + const team = await ensureTeam(ORG, SMOKE_NR_TEAM) + if (!team) { logFail('Phase 16: could not create/find smoke team'); return } + const teamId = team.id + const role = await createCustomRepositoryRole(ORG, SMOKE_NR_ROLE, 'safe-settings smoke name-resolution role') + if (!role) { logFail('Phase 16: could not create custom repository role'); return } + const roleId = role.id + log(`Smoke team id=${teamId}, custom role id=${roleId}`) + + // Optional principals — only exercised when the env vars are provided. + const extraActors = [] + if (process.env.SMOKE_NR_USER) extraActors.push({ name: process.env.SMOKE_NR_USER, actor_type: 'User', bypass_mode: 'always' }) + if (process.env.SMOKE_NR_APP_SLUG) extraActors.push({ name: process.env.SMOKE_NR_APP_SLUG, actor_type: 'Integration', bypass_mode: 'always' }) + + // ── 16a: Create a ruleset entirely by name (Team, built-in + custom role, reviewer slug) ── + const createActors = [ + { name: SMOKE_NR_TEAM, actor_type: 'Team', bypass_mode: 'always' }, + { name: 'maintain', actor_type: 'RepositoryRole', bypass_mode: 'always' }, + { name: SMOKE_NR_ROLE, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' }, + ...extraActors + ] + { + const branch = 'smoke-test-phase16a' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML + buildNameResolutionRuleset(createActors), branch, '16a: add name-resolution ruleset') + const pr = await createPR(ORG, ADMIN_REPO, '16a: ruleset bypass actor.name + reviewer.slug', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '16a: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `16a: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const details = await poll(async () => { + const rs = await getRepoRuleset(ORG, 'test', RULESET) + if (!rs) return null + return await getRepoRulesetDetails(ORG, 'test', rs.id) + }, { desc: 'name-resolution ruleset to be created', timeout: 90000 }) + + assert(details !== null, '16a: ruleset created from name-based config') + if (details) { + const actors = details.bypass_actors || [] + assert(actors.some(a => a.actor_type === 'Team' && a.actor_id === teamId), `16a: Team name resolved to actor_id ${teamId}`) + assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 4), '16a: built-in role "maintain" resolved to actor_id 4') + assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === roleId), `16a: custom role resolved to actor_id ${roleId}`) + // GitHub only ever stores ids; the human-friendly alias must not leak through. + assert(actors.every(a => a.name === undefined), '16a: no "name" alias present in applied ruleset (resolved to ids)') + + const prRule = (details.rules || []).find(r => r.type === 'pull_request') + const reviewers = prRule && prRule.parameters && prRule.parameters.required_reviewers + const reviewer = Array.isArray(reviewers) && reviewers[0] && reviewers[0].reviewer + assert(reviewer && reviewer.id === teamId, `16a: reviewer.slug resolved to team id ${teamId}`) + + if (process.env.SMOKE_NR_USER) assert(actors.some(a => a.actor_type === 'User' && Number.isInteger(a.actor_id)), '16a: User name resolved to actor_id') + if (process.env.SMOKE_NR_APP_SLUG) assert(actors.some(a => a.actor_type === 'Integration' && Number.isInteger(a.actor_id)), '16a: Integration slug resolved to actor_id') + } + await deleteBranch(ORG, ADMIN_REPO, branch) + } + + // ── 16b: Modify the ruleset by name — swap built-in maintain(4) → admin(5) ── + const modifyActors = createActors.map(a => + (a.actor_type === 'RepositoryRole' && a.name === 'maintain') ? { ...a, name: 'admin' } : a) + { + const branch = 'smoke-test-phase16b' + await deleteBranch(ORG, ADMIN_REPO, branch) + await createBranch(ORG, ADMIN_REPO, branch) + await createOrUpdateFile(ORG, ADMIN_REPO, `${CONFIG_PATH}/repos/test.yml`, REPO_TEST_YML + buildNameResolutionRuleset(modifyActors), branch, '16b: modify name-resolution ruleset') + const pr = await createPR(ORG, ADMIN_REPO, '16b: modify ruleset bypass actor by name', branch, defaultBranch) + log('Waiting for NOP check run...') + await sleep(WEBHOOK_SETTLE_MS) + const checkRun = await waitForCheckRun(ORG, ADMIN_REPO, pr.head.sha) + assert(checkRun !== null, '16b: NOP check run completed') + if (checkRun) assert(checkRun.conclusion === 'success', `16b: NOP check run is success (got: ${checkRun.conclusion})`) + if (!await safeMerge(ORG, ADMIN_REPO, pr.number)) return + await sleep(WEBHOOK_SETTLE_MS) + + const updated = await poll(async () => { + const rs = await getRepoRuleset(ORG, 'test', RULESET) + if (!rs) return null + const d = await getRepoRulesetDetails(ORG, 'test', rs.id) + const actors = (d && d.bypass_actors) || [] + const hasAdmin = actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 5) + const hasMaintain = actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === 4) + return (hasAdmin && !hasMaintain) ? d : null + }, { desc: 'ruleset to be updated with admin role (5) replacing maintain (4)', timeout: 90000 }) + + assert(updated !== null, '16b: ruleset modified by name — maintain(4) replaced with admin(5)') + if (updated) { + const actors = updated.bypass_actors || [] + assert(actors.some(a => a.actor_type === 'Team' && a.actor_id === teamId), '16b: Team bypass actor preserved across modification') + assert(actors.some(a => a.actor_type === 'RepositoryRole' && a.actor_id === roleId), '16b: custom role bypass actor preserved across modification') + } + await deleteBranch(ORG, ADMIN_REPO, branch) + } +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main () { + const { App } = await import('octokit') + const app = new App({ appId: APP_ID, privateKey: PRIVATE_KEY }) + + // Find installation for our org + let installationId + for await (const { installation } of app.eachInstallation.iterator()) { + if (installation.account && installation.account.login.toLowerCase() === ORG.toLowerCase()) { + installationId = installation.id + break + } + } + if (!installationId) throw new Error(`No installation found for org ${ORG}`) + + octokit = await app.getInstallationOctokit(installationId) + log('Authenticated as GitHub App installation') + + console.log(` +\x1b[36m╔══════════════════════════════════════╗ +║ Safe-Settings Smoke Test ║ +║ Org: ${ORG.padEnd(28)}║ +║ Admin Repo: ${ADMIN_REPO.padEnd(22)}║ +╚══════════════════════════════════════╝\x1b[0m +`) + + if (INTERACTIVE) log('\x1b[33m[interactive] Mode enabled — will pause after each phase.\x1b[0m') + if (ONLY_PHASES !== null) log(`\x1b[33m[phase filter] Running setup + phase(s) [${[...ONLY_PHASES].join(', ')}] + teardown only.\x1b[0m`) + + let doTeardown = true + try { + const allPhases = [ + ['Phase 0: Setup', setup], + ['Phase 1: Create test repo', phase1CreateRepo], + ['Phase 2: Drift remediation - Team removal', phase2DriftTeam], + ['Phase 3: Drift remediation - Rogue ruleset', phase3DriftRuleset], + ['Phase 4: Create demo-repo-service1', phase4DemoRepo1], + ['Phase 5: Create suborg config', phase5Suborg], + ['Phase 6: Archive demo-repo-service1', phase6Archive], + ['Phase 7: Create demo-repo-service2', phase7DemoRepo2], + ['Phase 7b: External group team', phase7bExternalGroupTeam], + ['Phase 8: Org-level settings', phase8OrgSettings], + ['Phase 10: disable_plugins', phase10DisablePlugins], + ['Phase 11: additive_plugins', phase11AdditivePlugins], + ['Phase 12: custom_properties', phase12CustomProperties], + ['Phase 12: custom_repository_roles', phase12CustomRoles], + ['Phase 12: rulesets', phase12Rulesets], + ['Phase 13: variables', phase13Variables], + ['Phase 14: regressions', phase14RegressionCoverage], + ['Phase 15: Ruleset array drift', phase15RulesetArrayDrift], + ['Phase 16: Ruleset name/slug resolution', phase16RulesetNameResolution] + ] + + // When --phase is given, only run setup (phase 0) + the requested phase(s). + // Phase labels start with "Phase N:" so we match on that prefix. + const phases = ONLY_PHASES !== null + ? allPhases.filter(([label]) => { + if (label.startsWith('Phase 0:')) return true + const m = label.match(/^Phase (\d+)[:\s]/) + return m !== null && ONLY_PHASES.has(parseInt(m[1], 10)) + }) + : allPhases + + if (ONLY_PHASES !== null && phases.length < 2) { + const valid = allPhases.map(([label]) => label.replace(/^Phase (\S+):.*/, '$1')).filter(n => n !== '0').join(', ') + throw new Error(`No phases matching [${[...ONLY_PHASES].join(', ')}] found. Valid phase numbers: ${valid}`) + } + for (const [label, fn] of phases) { + const action = await runPhase(label, fn) + if (action === 'abort') { doTeardown = false; break } + if (action === 'quit') break + } + } catch (err) { + if (err instanceof InteractiveExit) { + if (err.action === 'abort') doTeardown = false + } else { + console.error(`\x1b[31mFatal error: ${err.message}\x1b[0m`) + console.error(err.stack) + } + } finally { + if (doTeardown) await teardown() + else log('\x1b[33m[interactive] Aborted — teardown skipped.\x1b[0m') + } + + console.log(` +\x1b[36m╔══════════════════════════════════════╗ +║ Results ║ +╚══════════════════════════════════════╝\x1b[0m + \x1b[32mPassed: ${passCount}\x1b[0m + \x1b[31mFailed: ${failCount}\x1b[0m +`) + + if (failures.length > 0) { + console.log('\x1b[31mFailures:\x1b[0m') + failures.forEach((f, i) => console.log(` ${i + 1}. ${f}`)) + console.log() + } + + process.exit(failCount > 0 ? 1 : 0) +} + +main().catch(err => { + console.error(err) + stopSafeSettings() + process.exit(1) +}) diff --git a/test/unit/index.test.js b/test/unit/index.test.js index feae42d95..b053e845f 100644 --- a/test/unit/index.test.js +++ b/test/unit/index.test.js @@ -1,16 +1,18 @@ const { Probot } = require('probot') const plugin = require('../../index') +jest.mock('../../lib/hubSyncHandler', () => ({ hubSyncHandler: jest.fn() })) +const { hubSyncHandler } = require('../../lib/hubSyncHandler') describe.skip('plugin', () => { let app, event, sync, github beforeEach(() => { class Octokit { - static defaults () { + static defaults() { return Octokit } - constructor () { + constructor() { this.config = { get: jest.fn().mockReturnValue({}) } @@ -19,7 +21,7 @@ describe.skip('plugin', () => { } } - auth () { + auth() { return this } } @@ -39,6 +41,8 @@ describe.skip('plugin', () => { sync = jest.fn() plugin(app, {}, { sync, FILE_NAME: '.github/settings.yml' }) + jest.clearAllMocks() + }) describe('with settings modified on master', () => { diff --git a/test/unit/lib/hubSyncHandler.test.js b/test/unit/lib/hubSyncHandler.test.js new file mode 100644 index 000000000..618d1f324 --- /dev/null +++ b/test/unit/lib/hubSyncHandler.test.js @@ -0,0 +1,106 @@ + +// Import the functions to test from the implementation file +const { hubSyncHandler, retrieveSettingsFromOrgs } = require('../../../lib/hubSyncHandler') + +// --- Mock dependencies --- +// Mock the env module to provide controlled environment variables for tests +jest.mock('../../../lib/env', () => ({ + SAFE_SETTINGS_HUB_ORG: 'test-org', // Simulate the hub org name + SAFE_SETTINGS_HUB_REPO: 'test-repo', // Simulate the hub repo name + ADMIN_REPO: 'admin-repo', // Simulate the admin repo name + CONFIG_PATH: '.github', // Simulate the config path + SAFE_SETTINGS_HUB_PATH: 'safe-settings', // Simulate the hub path + SAFE_SETTINGS_HUB_DIRECT_PUSH: 'true' // Simulate direct push mode +})) +// Mock the installationCache module to control installation lookups +jest.mock('../../../lib/installationCache', () => ({ + getInstallations: jest.fn() +})) + +// --- Create mock objects for robot and context --- +// Mock robot object with logging and auth methods +const mockRobot = { + log: { + info: jest.fn(), // Track info logs + warn: jest.fn(), // Track warning logs + error: jest.fn() // Track error logs + }, + auth: jest.fn() // Mock authentication method +} + +// Mock context object to simulate GitHub event payloads and API +const mockContext = { + payload: { + repository: { + name: 'test-repo', // Simulate repo name + owner: { login: 'test-org' }, // Simulate repo owner + full_name: 'test-org/test-repo' // Simulate full repo name + }, + pull_request: { number: 1, head: { sha: 'abc123' } } // Simulate pull request info + }, + repo: () => ({ owner: 'test-org', repo: 'test-repo' }), // Simulate repo lookup + octokit: { + paginate: jest.fn(), // Mock pagination for API calls + rest: { + pulls: { + listFiles: jest.fn() // Mock listFiles API + } + } + } +} + +// --- Unit tests for hubSyncHandler --- +describe('hubSyncHandler', () => { + // Test that hubSyncHandler ignores events from non-master repo/org + it('should ignore non-master repo/org', async () => { + const context = { ...mockContext, payload: { repository: { name: 'other', owner: { login: 'other' } } } } + await hubSyncHandler(mockRobot, context) + expect(mockRobot.log.info).toHaveBeenCalledWith(expect.stringContaining('ignoring')) + }) + + // Test routing for organizations folder changes + it('should call syncHubOrgUpdate for organizations folder changes', async () => { + const orgFile = '.github/safe-settings/organizations/acme/settings.yml' + const files = [{ filename: orgFile }] + const context = { + ...mockContext, + octokit: { ...mockContext.octokit, paginate: jest.fn().mockResolvedValue(files) }, + payload: { ...mockContext.payload, repository: { name: 'test-repo', owner: { login: 'test-org' }, full_name: 'test-org/test-repo' }, pull_request: { number: 1, head: { sha: 'abc123' } } } + } + const mod = require('../../../lib/hubSyncHandler') + // Spy on syncHubOrgUpdate + const spy = jest.spyOn(mod, 'syncHubOrgUpdate').mockImplementation(jest.fn()) + await mod.hubSyncHandler(mockRobot, context) + expect(spy).toHaveBeenCalledWith(mockRobot, context, 'acme', expect.anything(), expect.anything()) + spy.mockRestore() + }) + + // Test routing for globals folder changes + it('should call syncHubGlobalsUpdate for globals folder changes', async () => { + const globalsFile = '.github/safe-settings/globals/foo.yml' + const files = [{ filename: globalsFile }] + const context = { + ...mockContext, + octokit: { ...mockContext.octokit, paginate: jest.fn().mockResolvedValue(files) }, + payload: { ...mockContext.payload, repository: { name: 'test-repo', owner: { login: 'test-org' }, full_name: 'test-org/test-repo' }, pull_request: { number: 1, head: { sha: 'abc123' } } } + } + const mod = require('../../../lib/hubSyncHandler') + // Spy on syncHubGlobalsUpdate + const spy = jest.spyOn(mod, 'syncHubGlobalsUpdate').mockImplementation(jest.fn()) + await mod.hubSyncHandler(mockRobot, context) + expect(spy).toHaveBeenCalledWith(mockRobot, context, files) + spy.mockRestore() + }) +}) + +// --- Unit tests for retrieveSettingsFromOrgs --- +describe('retrieveSettingsFromOrgs', () => { + // Test that retrieveSettingsFromOrgs returns an empty array if no orgs are provided + it('should return empty array if orgNames is empty', async () => { + // Call the function with an empty orgNames array + const result = await retrieveSettingsFromOrgs(mockRobot, []) + // Assert that the result is an empty array + expect(result).toEqual([]) + }) + // Additional tests can be added here to cover error handling, file import, etc. +}) diff --git a/test/unit/lib/mergeConfigs.test.js b/test/unit/lib/mergeConfigs.test.js new file mode 100644 index 000000000..14178e441 --- /dev/null +++ b/test/unit/lib/mergeConfigs.test.js @@ -0,0 +1,453 @@ +const { mergeConfigs } = require('../../../lib/hubSyncHandler') + +describe('mergeConfigs', () => { + describe('Array handling', () => { + const json1 = ` +teams: + - team-a + - team-b +` + + const json2 = ` +teams: + - team-c +` + + it('should replace arrays when replaceArrays=true', () => { + const result = mergeConfigs(json1, json2, true) + expect(result).toEqual({ + teams: ['team-c'] + }) + }) + + it('should smart merge arrays when replaceArrays=false (deduplicate)', () => { + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + teams: ['team-a', 'team-b', 'team-c'] + }) + }) + + it('should not create duplicates in smart merge mode', () => { + const json1 = ` +teams: + - team-a + - team-b +` + const json2 = ` +teams: + - team-b + - team-c +` + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + teams: ['team-a', 'team-b', 'team-c'] + }) + }) + + it('should default to replace mode when replaceArrays not specified', () => { + const result = mergeConfigs(json1, json2) + expect(result).toEqual({ + teams: ['team-c'] + }) + }) + }) + + describe('Object merging', () => { + const json1 = ` +repository: + private: true + has_issues: true +` + + const json2 = ` +repository: + visibility: internal + has_issues: false +` + + it('should merge objects recursively', () => { + const result = mergeConfigs(json1, json2, true) + expect(result).toEqual({ + repository: { + private: true, + visibility: 'internal', + has_issues: false + } + }) + }) + }) + + describe('Complex nested structures', () => { + const json1 = ` +repository: + settings: + security: + scanning: true + teams: + - team-a + - team-b +labels: + - name: bug + color: red +` + + const json2 = ` +repository: + settings: + security: + alerts: true + teams: + - team-c +labels: + - name: feature + color: blue +` + + it('should handle complex nesting with array replace', () => { + const result = mergeConfigs(json1, json2, true) + expect(result).toEqual({ + repository: { + settings: { + security: { + scanning: true, + alerts: true + } + }, + teams: ['team-c'] + }, + labels: [ + { name: 'feature', color: 'blue' } + ] + }) + }) + + it('should handle complex nesting with smart array merge', () => { + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + repository: { + settings: { + security: { + scanning: true, + alerts: true + } + }, + teams: ['team-a', 'team-b', 'team-c'] + }, + labels: [ + { name: 'bug', color: 'red' }, + { name: 'feature', color: 'blue' } + ] + }) + }) + + it('should merge matching objects in arrays by name property', () => { + const json1 = ` +collaborators: + - username: alice + permission: push + - username: bob + permission: pull +` + const json2 = ` +collaborators: + - username: alice + permission: admin + - username: charlie + permission: push +` + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + collaborators: [ + { username: 'alice', permission: 'admin' }, + { username: 'bob', permission: 'pull' }, + { username: 'charlie', permission: 'push' } + ] + }) + }) + }) + + describe('JSON input', () => { + it('should handle JSON strings', () => { + const json1 = JSON.stringify({ teams: ['team-a', 'team-b'] }) + const json2 = JSON.stringify({ teams: ['team-c'] }) + + const replaced = mergeConfigs(json1, json2, true) + expect(replaced).toEqual({ teams: ['team-c'] }) + + const appended = mergeConfigs(json1, json2, false) + expect(appended).toEqual({ teams: ['team-a', 'team-b', 'team-c'] }) + }) + }) + + describe('Edge cases', () => { + it('should handle empty strings', () => { + const result = mergeConfigs('', 'teams: [team-a]', true) + expect(result).toEqual({ teams: ['team-a'] }) + }) + + it('should handle null/undefined values', () => { + const json1 = 'a: 1\nb: 2' + const json2 = 'b: null' + + const result = mergeConfigs(json1, json2, true) + expect(result).toEqual({ a: 1, b: null }) + }) + + it('should skip prototype pollution', () => { + const json1 = 'a: 1' + const json2 = '__proto__: {polluted: true}' + + const result = mergeConfigs(json1, json2, true) + expect(result.polluted).toBeUndefined() + }) + }) + + describe('Real-world example from documentation', () => { + it('should merge global and org settings with replace', () => { + const globalSettings = ` +repository: + private: true +` + + const orgSettings = ` +repository: + visibility: internal +` + + const result = mergeConfigs(globalSettings, orgSettings, true) + expect(result).toEqual({ + repository: { + private: true, + visibility: 'internal' + } + }) + }) + }) + + describe('Combined arrays and primitives', () => { + it('should merge primitives and object arrays together (replace mode)', () => { + const json1 = ` +policy_name: P1 +version: 1.2 +enabled: true +labels: + - name: bug + color: red + - name: feature + color: blue +collaborators: + - username: alice + permission: push +` + + const json2 = ` +policy_name: P2 +description: "Updated policy" +labels: + - name: enhancement + color: green +collaborators: + - username: alice + permission: admin + - username: bob + permission: pull +` + + const result = mergeConfigs(json1, json2, true) + expect(result).toEqual({ + policy_name: 'P2', + version: 1.2, + enabled: true, + description: 'Updated policy', + labels: [ + { name: 'enhancement', color: 'green' } + ], + collaborators: [ + { username: 'alice', permission: 'admin' }, + { username: 'bob', permission: 'pull' } + ] + }) + }) + + it('should merge primitives and object arrays together (smart merge mode)', () => { + const json1 = ` +policy_name: P1 +version: 1.2 +enabled: true +labels: + - name: bug + color: red + priority: high + - name: feature + color: blue +collaborators: + - username: alice + permission: push +` + + const json2 = ` +policy_name: P2 +description: "Updated policy" +labels: + - name: bug + color: darkred + - name: enhancement + color: green +collaborators: + - username: alice + permission: admin + - username: bob + permission: pull +` + + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + policy_name: 'P2', + version: 1.2, + enabled: true, + description: 'Updated policy', + labels: [ + { name: 'bug', color: 'darkred', priority: 'high' }, + { name: 'feature', color: 'blue' }, + { name: 'enhancement', color: 'green' } + ], + collaborators: [ + { username: 'alice', permission: 'admin' }, + { username: 'bob', permission: 'pull' } + ] + }) + }) + + it('should handle mixed primitive arrays and object arrays (smart merge)', () => { + const json1 = ` +name: Project A +tags: + - typescript + - nodejs +labels: + - name: bug + color: red +teams: + - dev-team + - qa-team +` + + const json2 = ` +name: Project B +tags: + - nodejs + - docker +labels: + - name: bug + color: blue + - name: feature + color: green +teams: + - qa-team + - ops-team +` + + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + name: 'Project B', + tags: ['typescript', 'nodejs', 'docker'], + labels: [ + { name: 'bug', color: 'blue' }, + { name: 'feature', color: 'green' } + ], + teams: ['dev-team', 'qa-team', 'ops-team'] + }) + }) + + it('should handle deeply nested structures with primitives and arrays', () => { + const json1 = ` +config: + version: 1.0 + settings: + security: + enabled: true + level: high + features: + - authentication + - authorization + policies: + - name: default + priority: 1 +` + + const json2 = ` +config: + version: 2.0 + settings: + security: + level: critical + features: + - authorization + - monitoring + policies: + - name: default + priority: 5 + description: Updated policy + - name: custom + priority: 2 +` + + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + config: { + version: 2.0, + settings: { + security: { + enabled: true, + level: 'critical' + }, + features: ['authentication', 'authorization', 'monitoring'] + }, + policies: [ + { name: 'default', priority: 5, description: 'Updated policy' }, + { name: 'custom', priority: 2 } + ] + } + }) + }) + + it('should preserve primitives when merging complex nested arrays', () => { + const json1 = ` +organization: acme-corp +type: enterprise +max_repos: 100 +members: + - username: alice + role: admin + active: true + - username: bob + role: member + active: true +` + + const json2 = ` +type: organization +description: ACME Corporation +members: + - username: alice + role: owner + - username: charlie + role: member + active: false +` + + const result = mergeConfigs(json1, json2, false) + expect(result).toEqual({ + organization: 'acme-corp', + type: 'organization', + max_repos: 100, + description: 'ACME Corporation', + members: [ + { username: 'alice', role: 'owner', active: true }, + { username: 'bob', role: 'member', active: true }, + { username: 'charlie', role: 'member', active: false } + ] + }) + }) + }) +}) diff --git a/test/unit/lib/mergeDeep.test.js b/test/unit/lib/mergeDeep.test.js index dd8bd60ba..0d6506781 100644 --- a/test/unit/lib/mergeDeep.test.js +++ b/test/unit/lib/mergeDeep.test.js @@ -1314,6 +1314,151 @@ entries: // console.log(`diffs ${JSON.stringify(merged, null, 2)}`) }) + it('Ruleset Compare detects required_reviewers removal without bypass_actors churn', () => { + // Existing ruleset in GitHub: has required_reviewers and a server-defaulted + // allowed_merge_methods. GitHub returns actor_id: null for the OrganizationAdmin + // bypass actor. + const target = { + id: 12345, + name: 'synk', + target: 'branch', + source_type: 'Repository', + source: 'decyjphr-org/test', + enforcement: 'active', + node_id: 'RRS_xxx', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + current_user_can_bypass: 'always', + _links: { self: { href: 'https://api.github.com/repos/x/y/rulesets/12345' } }, + bypass_actors: [ + { actor_id: null, actor_type: 'OrganizationAdmin', bypass_mode: 'pull_request' } + ], + conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } }, + rules: [ + { + type: 'pull_request', + parameters: { + dismiss_stale_reviews_on_push: true, + require_code_owner_review: false, + require_last_push_approval: false, + required_approving_review_count: 2, + required_review_thread_resolution: false, + required_reviewers: [ + { minimum_approvals: 1, file_patterns: ['*.js'], reviewer: { id: 11721733, type: 'Team' } } + ], + allowed_merge_methods: ['merge', 'squash', 'rebase'] + } + } + ] + } + // Config: required_reviewers removed, OrganizationAdmin bypass actor with explicit id. + const source = { + name: 'synk', + target: 'branch', + enforcement: 'active', + bypass_actors: [ + { actor_id: 1, actor_type: 'OrganizationAdmin', bypass_mode: 'pull_request' } + ], + conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } }, + rules: [ + { + type: 'pull_request', + parameters: { + dismiss_stale_reviews_on_push: true, + require_code_owner_review: false, + require_last_push_approval: false, + required_approving_review_count: 2, + required_review_thread_resolution: false + } + } + ] + } + const ignorableFields = [] + const mockReturnGitHubContext = jest.fn().mockReturnValue({ + request: () => {} + }) + const mergeDeep = new MergeDeep( + log, + mockReturnGitHubContext, + ignorableFields + ) + const merged = mergeDeep.compareDeep(target, source) + + // The removal of required_reviewers must be detected as a change. + expect(merged.hasChanges).toBeTruthy() + expect(merged.deletions.rules[0].parameters.required_reviewers).toEqual([ + { minimum_approvals: 1, file_patterns: ['*.js'], reviewer: { id: 11721733, type: 'Team' } } + ]) + // The OrganizationAdmin bypass actor (actor_id 1 vs null) must NOT churn. + expect(merged.additions.bypass_actors).toBeUndefined() + expect(merged.deletions.bypass_actors).toBeUndefined() + // allowed_merge_methods is a server-managed default and must NOT be a deletion. + expect(merged.deletions.rules[0].parameters.allowed_merge_methods).toBeUndefined() + }) + + it('Ruleset Compare reports no change when unnamed object array keys are reordered', () => { + // code_scanning_tools elements are keyed by `tool` (not a NAME_FIELD), so they + // fall back to a stable identity. GitHub returns the object keys in a different + // order than config; this must NOT produce spurious add/modify/delete churn. + const target = { + id: 17806629, + name: 'Prevent merges when new SONAR alerts are introduced', + target: 'branch', + source_type: 'Repository', + source: 'decyjphr-emu/test', + enforcement: 'active', + node_id: 'RRS_xxx', + created_at: '2026-06-17T18:45:28.141Z', + updated_at: '2026-06-17T18:45:28.162Z', + current_user_can_bypass: 'always', + _links: { self: { href: 'https://x' }, html: { href: 'https://y' } }, + bypass_actors: [ + { actor_id: null, actor_type: 'OrganizationAdmin', bypass_mode: 'always' } + ], + conditions: { ref_name: { exclude: [], include: ['~DEFAULT_BRANCH'] } }, + rules: [ + { + type: 'code_scanning', + parameters: { + code_scanning_tools: [ + { tool: 'Sonar', security_alerts_threshold: 'medium_or_higher', alerts_threshold: 'none' } + ] + } + } + ] + } + const source = { + name: 'Prevent merges when new SONAR alerts are introduced', + target: 'branch', + enforcement: 'active', + conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } }, + bypass_actors: [ + { actor_type: 'OrganizationAdmin', bypass_mode: 'always' } + ], + rules: [ + { + type: 'code_scanning', + parameters: { + code_scanning_tools: [ + { tool: 'Sonar', alerts_threshold: 'none', security_alerts_threshold: 'medium_or_higher' } + ] + } + } + ] + } + const ignorableFields = [] + const mockReturnGitHubContext = jest.fn().mockReturnValue({ + request: () => {} + }) + const mergeDeep = new MergeDeep( + log, + mockReturnGitHubContext, + ignorableFields + ) + const merged = mergeDeep.compareDeep(target, source) + expect(merged.hasChanges).toBeFalsy() + }) + it('Ruleset Compare Works when required_status_checks change', () => { const target = [ { diff --git a/test/unit/lib/plugins/environments.test.js b/test/unit/lib/plugins/environments.test.js index 31fbb1cdf..d9c974f1c 100644 --- a/test/unit/lib/plugins/environments.test.js +++ b/test/unit/lib/plugins/environments.test.js @@ -1,6 +1,6 @@ const { when } = require('jest-when') const Environments = require('../../../../lib/plugins/environments') -const NopCommand = require('../../../../lib/nopcommand'); +const NopCommand = require('../../../../lib/nopcommand') describe('Environments Plugin test suite', () => { let github @@ -312,7 +312,7 @@ describe('Environments Plugin test suite', () => { protected_branches: false, custom_branch_policies: [ { - names: ['main','dev'], + names: ['main', 'dev'], type: 'branch' }, { @@ -389,7 +389,7 @@ describe('Environments Plugin test suite', () => { name: environmentName, deployment_branch_policy: { protected_branches: false, - custom_branch_policies: ["main", "dev"] + custom_branch_policies: ['main', 'dev'] } } ], log, errors) @@ -841,7 +841,7 @@ describe('Environments Plugin test suite', () => { protected_branches: false, custom_branch_policies: [ { - names: ['main','dev'], + names: ['main', 'dev'], type: 'branch' }, { @@ -855,7 +855,7 @@ describe('Environments Plugin test suite', () => { name: 'deployment-branch-policy-custom_environment_legacy', deployment_branch_policy: { protected_branches: false, - custom_branch_policies: ["main", "dev"] + custom_branch_policies: ['main', 'dev'] } }, { @@ -1098,7 +1098,7 @@ describe('Environments Plugin test suite', () => { protected_branches: false, custom_branch_policies: [ { - names: ['main','dev'], + names: ['main', 'dev'], type: 'branch' }, { @@ -1112,7 +1112,7 @@ describe('Environments Plugin test suite', () => { name: 'deployment-branch-policy-custom_environment_legacy', deployment_branch_policy: { protected_branches: false, - custom_branch_policies: ["main", "dev"] + custom_branch_policies: ['main', 'dev'] } }, { @@ -1166,7 +1166,7 @@ describe('Environments Plugin test suite', () => { protected_branches: false, custom_branch_policies: [ { - names: ['main','dev'], + names: ['main', 'dev'], type: 'branch' }, { @@ -1180,7 +1180,7 @@ describe('Environments Plugin test suite', () => { name: 'new-deployment-branch-policy-custom-legacy', deployment_branch_policy: { protected_branches: false, - custom_branch_policies: ["main", "dev"] + custom_branch_policies: ['main', 'dev'] } }, { @@ -1396,37 +1396,37 @@ describe('Environments Plugin test suite', () => { }) describe('nopifyRequest', () => { - let github; - let plugin; - const org = 'bkeepers'; - const repo = 'test'; - const environment_name = 'test-environment'; - const url = 'PUT /repos/:org/:repo/environments/:environment_name'; - const options = { org, repo, environment_name, wait_timer: 1 }; - const description = 'Update environment wait timer'; + let github + let plugin + const org = 'bkeepers' + const repo = 'test' + const environment_name = 'test-environment' + const url = 'PUT /repos/:org/:repo/environments/:environment_name' + const options = { org, repo, environment_name, wait_timer: 1 } + const description = 'Update environment wait timer' beforeEach(() => { github = { request: jest.fn(() => Promise.resolve(true)) - }; - plugin = new Environments(undefined, github, { owner: org, repo }, [], { debug: jest.fn(), error: console.error }, []); - }); + } + plugin = new Environments(undefined, github, { owner: org, repo }, [], { debug: jest.fn(), error: console.error }, []) + }) it('should make a request when nop is false', async () => { - plugin.nop = false; + plugin.nop = false - await plugin.nopifyRequest(url, options, description); + await plugin.nopifyRequest(url, options, description) - expect(github.request).toHaveBeenCalledWith(url, options); - }); + expect(github.request).toHaveBeenCalledWith(url, options) + }) it('should return NopCommand when nop is true', async () => { - plugin.nop = true; + plugin.nop = true - const result = await plugin.nopifyRequest(url, options, description); + const result = await plugin.nopifyRequest(url, options, description) expect(result).toEqual([ new NopCommand('Environments', { owner: org, repo }, url, description) - ]); - }); -}); + ]) + }) +}) diff --git a/test/unit/lib/plugins/rulesets.test.js b/test/unit/lib/plugins/rulesets.test.js index f15abd63f..3dd8f454f 100644 --- a/test/unit/lib/plugins/rulesets.test.js +++ b/test/unit/lib/plugins/rulesets.test.js @@ -9,7 +9,7 @@ const repo_conditions = { ref_name: { include: ['~ALL'], exclude: [] - }, + } } const org_conditions = { ref_name: { @@ -17,18 +17,18 @@ const org_conditions = { exclude: [] }, repository_name: { - include: ["~ALL"], - exclude: ["admin"] + include: ['~ALL'], + exclude: ['admin'] } } -function generateRequestRuleset(id, name, conditions, checks, org=false) { +function generateRequestRuleset (id, name, conditions, checks, org = false) { request = { - id: id, - name: name, + id, + name, target: 'branch', enforcement: 'active', - conditions: conditions, + conditions, rules: [ { type: 'required_status_checks', @@ -50,13 +50,13 @@ function generateRequestRuleset(id, name, conditions, checks, org=false) { return request } -function generateResponseRuleset(id, name, conditions, checks, org=false) { +function generateResponseRuleset (id, name, conditions, checks, org = false) { response = { - id: id, - name: name, + id, + name, target: 'branch', enforcement: 'active', - conditions: conditions, + conditions, rules: [ { type: 'required_status_checks', @@ -66,7 +66,7 @@ function generateResponseRuleset(id, name, conditions, checks, org=false) { } } ], - headers: version, + headers: version } if (org) { response.source_type = 'Organization' @@ -88,8 +88,7 @@ describe('Rulesets', () => { log.debug = jest.fn() log.error = jest.fn() - function configure (config, scope='repo') { - const noop = false + function configure (config, scope = 'repo', noop = false) { const errors = [] return new Rulesets(noop, github, { owner: 'jitran', repo: 'test' }, config, log, errors, scope) } @@ -103,17 +102,15 @@ describe('Rulesets', () => { } }) }, - request: jest.fn().mockImplementation(() => Promise.resolve('request')), + request: jest.fn().mockImplementation(() => Promise.resolve('request')) } - github.request.endpoint = { - merge: jest.fn().mockReturnValue({ - method: 'GET', - url: '/repos/jitran/test/rulesets', - headers: version - } - ) - } + github.request.endpoint = jest.fn().mockImplementation((route, body) => ({ url: route, body })) + github.request.endpoint.merge = jest.fn().mockReturnValue({ + method: 'GET', + url: '/repos/jitran/test/rulesets', + headers: version + }) }) describe('sync', () => { @@ -151,6 +148,35 @@ describe('Rulesets', () => { ) }) }) + + it('in nop mode treats a missing repo as having no existing rulesets', async () => { + const notFound = new Error('Not Found') + notFound.status = 404 + github.paginate = jest.fn().mockRejectedValue(notFound) + + const plugin = configure( + [ + generateRequestRuleset( + 1, + 'All branches', + repo_conditions, + [ + { context: 'Status Check 1' } + ] + ) + ], + 'repo', + true + ) + + const result = await plugin.sync() + const flat = result.flat() + const summary = flat.find(command => command.plugin === 'Rulesets' && command.action?.msg === 'Changes found') + + expect(flat.some(command => command.type === 'ERROR')).toBe(false) + expect(summary.action.additions['0']).toEqual(expect.objectContaining({ name: 'All branches' })) + expect(summary.action.deletions).toBeUndefined() + }) }) describe('when {{EXTERNALLY_DEFINED}} is present in "required_status_checks" and no status checks exist in GitHub', () => { @@ -419,4 +445,573 @@ describe('Rulesets', () => { }) }) }) + + describe('changed() method with required_reviewers', () => { + it('detects when required_reviewers array changes from populated to empty', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Protect release branches', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/release/*'], + exclude: [] + } + }, + rules: [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['merge', 'squash', 'rebase'], + required_reviewers: [ + { + minimum_approvals: 1, + file_patterns: ['*.js'], + reviewer: { + id: 11721733, + type: 'Team' + } + } + ] + } + } + ] + } + ]) + + // GitHub state after manual removal of required_reviewers + const existingRuleset = { + name: 'Protect release branches', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/release/*'], + exclude: [] + } + }, + rules: [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['merge', 'squash', 'rebase'], + required_reviewers: [] // Empty after manual removal + } + } + ] + } + + // YAML config (what safe-settings expects) + const attrs = plugin.rulesets[0] + + // The changed() method should detect this difference + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + + it('detects when bypass_actors array changes from populated to empty', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Main protection', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + bypass_actors: [ + { + actor_type: 'OrganizationAdmin', + bypass_mode: 'always' + } + ], + rules: [ + { + type: 'creation' + } + ] + } + ]) + + // GitHub state after manual removal of bypass_actors + const existingRuleset = { + name: 'Main protection', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + bypass_actors: [], // Empty after manual removal + rules: [ + { + type: 'creation' + } + ] + } + + const attrs = plugin.rulesets[0] + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + + it('detects when workflows array changes from populated to empty', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Workflow protection', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'workflows', + parameters: { + do_not_enforce_on_create: false, + workflows: [ + { + path: '.github/workflows/test.yml', + repository_id: 123456 + } + ] + } + } + ] + } + ]) + + // GitHub state after manual removal of workflows + const existingRuleset = { + name: 'Workflow protection', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'workflows', + parameters: { + do_not_enforce_on_create: false, + workflows: [] // Empty after manual removal + } + } + ] + } + + const attrs = plugin.rulesets[0] + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + + it('detects when rules array has item added out-of-band', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Branch rules', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'creation' + } + ] + } + ]) + + // GitHub state where an extra rule was added manually + const existingRuleset = { + name: 'Branch rules', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'creation' + }, + { + type: 'deletion' // Extra rule added out-of-band + } + ] + } + + const attrs = plugin.rulesets[0] + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + + it('detects when required_reviewers item is modified with different file patterns', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Code review', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['merge'], + required_reviewers: [ + { + minimum_approvals: 1, + file_patterns: ['*.js', '*.ts'], + reviewer: { + id: 999, + type: 'Team' + } + } + ] + } + } + ] + } + ]) + + // GitHub state where file patterns were manually changed + const existingRuleset = { + name: 'Code review', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + rules: [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['merge'], + required_reviewers: [ + { + minimum_approvals: 1, + file_patterns: ['*.py'], // Different patterns + reviewer: { + id: 999, + type: 'Team' + } + } + ] + } + } + ] + } + + const attrs = plugin.rulesets[0] + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + + it('detects when bypass_actors item is modified with different bypass_mode', () => { + github.paginate = jest.fn().mockResolvedValue([]) + + const plugin = configure([ + { + name: 'Bypass config', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + bypass_actors: [ + { + actor_type: 'OrganizationAdmin', + bypass_mode: 'always' + } + ], + rules: [ + { + type: 'creation' + } + ] + } + ]) + + // GitHub state where bypass_mode was manually changed + const existingRuleset = { + name: 'Bypass config', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/main'], + exclude: [] + } + }, + bypass_actors: [ + { + actor_type: 'OrganizationAdmin', + bypass_mode: 'pull_request' // Changed from 'always' + } + ], + rules: [ + { + type: 'creation' + } + ] + } + + const attrs = plugin.rulesets[0] + const result = plugin.changed(existingRuleset, attrs) + expect(result).toBe(true) + }) + }) + + describe('name to id resolution', () => { + function bypassRuleset (actorEntry) { + return { + name: 'Main protection', + target: 'branch', + enforcement: 'active', + conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } }, + bypass_actors: [Object.assign({ bypass_mode: 'always' }, actorEntry)], + rules: [{ type: 'creation' }] + } + } + + function reviewerRuleset (reviewer) { + return { + name: 'Code review', + target: 'branch', + enforcement: 'active', + conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } }, + rules: [ + { + type: 'pull_request', + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + required_reviewers: [ + { minimum_approvals: 1, file_patterns: ['*.js'], reviewer } + ] + } + } + ] + } + } + + it('resolves a Team bypass actor name to actor_id and strips the alias', async () => { + github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) } + const plugin = configure([bypassRuleset({ name: 'my-team', actor_type: 'Team' })], 'org') + + await plugin.resolveNamesToIds() + + expect(github.teams.getByName).toHaveBeenCalledWith({ org: 'jitran', team_slug: 'my-team' }) + expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 42, actor_type: 'Team', bypass_mode: 'always' }) + expect(plugin.rulesets[0].bypass_actors[0].name).toBeUndefined() + }) + + it('resolves a User bypass actor login to actor_id', async () => { + github.request = jest.fn().mockResolvedValue({ data: { id: 7 } }) + const plugin = configure([bypassRuleset({ name: 'octocat', actor_type: 'User' })], 'org') + + await plugin.resolveNamesToIds() + + expect(github.request).toHaveBeenCalledWith('GET /users/{username}', { username: 'octocat' }) + expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 7, actor_type: 'User', bypass_mode: 'always' }) + }) + + it('resolves an Integration (GitHub App) slug to actor_id', async () => { + github.request = jest.fn().mockResolvedValue({ data: { id: 99 } }) + const plugin = configure([bypassRuleset({ name: 'my-app', actor_type: 'Integration' })], 'org') + + await plugin.resolveNamesToIds() + + expect(github.request).toHaveBeenCalledWith('GET /apps/{app_slug}', { app_slug: 'my-app' }) + expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(99) + }) + + it('resolves built-in RepositoryRole names from the static map without an API call', async () => { + github.request = jest.fn() + const plugin = configure([ + bypassRuleset({ name: 'admin', actor_type: 'RepositoryRole' }) + ], 'org') + + await plugin.resolveNamesToIds() + + expect(github.request).not.toHaveBeenCalled() + expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(5) + }) + + it('pins the built-in RepositoryRole ids', async () => { + const expected = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 } + for (const [name, id] of Object.entries(expected)) { + const plugin = configure([bypassRuleset({ name, actor_type: 'RepositoryRole' })], 'org') + await plugin.resolveNamesToIds() + expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(id) + } + }) + + it('resolves a custom RepositoryRole name via the custom-repository-roles API', async () => { + github.request = jest.fn().mockResolvedValue({ data: { custom_roles: [{ id: 123, name: 'Security' }] } }) + const plugin = configure([bypassRuleset({ name: 'Security', actor_type: 'RepositoryRole' })], 'org') + + await plugin.resolveNamesToIds() + + expect(github.request).toHaveBeenCalledWith('GET /orgs/{org}/custom-repository-roles', { org: 'jitran' }) + expect(plugin.rulesets[0].bypass_actors[0].actor_id).toBe(123) + }) + + it('resolves a reviewer slug to id and strips the alias', async () => { + github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 555 } }) } + const plugin = configure([reviewerRuleset({ slug: 'reviewers', type: 'Team' })], 'org') + + await plugin.resolveNamesToIds() + + const reviewer = plugin.rulesets[0].rules[0].parameters.required_reviewers[0].reviewer + expect(github.teams.getByName).toHaveBeenCalledWith({ org: 'jitran', team_slug: 'reviewers' }) + expect(reviewer).toEqual({ id: 555, type: 'Team' }) + expect(reviewer.slug).toBeUndefined() + }) + + it('caches repeated lookups so each name resolves with a single API call', async () => { + github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) } + const plugin = configure([ + { + name: 'Multi', + target: 'branch', + enforcement: 'active', + conditions: { ref_name: { include: ['refs/heads/main'], exclude: [] } }, + bypass_actors: [ + { name: 'my-team', actor_type: 'Team', bypass_mode: 'always' }, + { name: 'my-team', actor_type: 'Team', bypass_mode: 'pull_request' } + ], + rules: [{ type: 'creation' }] + } + ], 'org') + + await plugin.resolveNamesToIds() + + expect(github.teams.getByName).toHaveBeenCalledTimes(1) + expect(plugin.rulesets[0].bypass_actors.map(a => a.actor_id)).toEqual([42, 42]) + }) + + it('leaves numeric actor_id untouched and makes no lookup (backward compatible)', async () => { + github.teams = { getByName: jest.fn() } + github.request = jest.fn() + const plugin = configure([bypassRuleset({ actor_id: 234, actor_type: 'Team' })], 'org') + + await plugin.resolveNamesToIds() + + expect(github.teams.getByName).not.toHaveBeenCalled() + expect(github.request).not.toHaveBeenCalled() + expect(plugin.rulesets[0].bypass_actors[0]).toEqual({ actor_id: 234, actor_type: 'Team', bypass_mode: 'always' }) + }) + + it('throws when both name and actor_id are provided', async () => { + const plugin = configure([bypassRuleset({ name: 'my-team', actor_id: 1, actor_type: 'Team' })], 'org') + await expect(plugin.resolveNamesToIds()).rejects.toThrow(/both 'name'.*and 'actor_id'/) + }) + + it('throws when both reviewer slug and id are provided', async () => { + const plugin = configure([reviewerRuleset({ slug: 'reviewers', id: 1, type: 'Team' })], 'org') + await expect(plugin.resolveNamesToIds()).rejects.toThrow(/both 'slug'.*and 'id'/) + }) + + it('throws when an actor_type does not support name resolution', async () => { + const plugin = configure([bypassRuleset({ name: 'whoever', actor_type: 'DeployKey' })], 'org') + await expect(plugin.resolveNamesToIds()).rejects.toThrow(/only supported for Team, User, Integration, and RepositoryRole/) + }) + + it('throws a clear error when a team slug cannot be resolved', async () => { + const notFound = new Error('Not Found') + notFound.status = 404 + github.teams = { getByName: jest.fn().mockRejectedValue(notFound) } + const plugin = configure([bypassRuleset({ name: 'ghost-team', actor_type: 'Team' })], 'org') + await expect(plugin.resolveNamesToIds()).rejects.toThrow(/Unable to resolve Team slug 'ghost-team'/) + }) + + it('sync sends the resolved actor_id to the API', async () => { + github.paginate = jest.fn().mockResolvedValue([]) + github.teams = { getByName: jest.fn().mockResolvedValue({ data: { id: 42 } }) } + const postCalls = [] + github.request = jest.fn().mockImplementation((route, body) => { + if (route.startsWith('POST')) postCalls.push({ route, body }) + return Promise.resolve('request') + }) + github.request.endpoint = jest.fn().mockImplementation((route, body) => ({ url: route, body })) + github.request.endpoint.merge = jest.fn().mockReturnValue({ method: 'GET', url: '/orgs/jitran/rulesets', headers: version }) + + const plugin = configure([bypassRuleset({ name: 'my-team', actor_type: 'Team' })], 'org') + await plugin.sync() + + expect(postCalls).toHaveLength(1) + expect(postCalls[0].route).toBe('POST /orgs/{org}/rulesets') + expect(postCalls[0].body.bypass_actors).toEqual([{ actor_id: 42, actor_type: 'Team', bypass_mode: 'always' }]) + }) + + it('sync surfaces a resolution failure as an error in nop mode', async () => { + github.paginate = jest.fn().mockResolvedValue([]) + const plugin = configure([bypassRuleset({ name: 'my-team', actor_id: 1, actor_type: 'Team' })], 'org', true) + + const result = await plugin.sync() + const flat = result.flat() + expect(flat.some(command => command.type === 'ERROR')).toBe(true) + }) + }) }) diff --git a/test/unit/lib/plugins/teams.test.js b/test/unit/lib/plugins/teams.test.js index 60ef23dbc..a521e0ce3 100644 --- a/test/unit/lib/plugins/teams.test.js +++ b/test/unit/lib/plugins/teams.test.js @@ -96,4 +96,190 @@ describe('Teams', () => { ) } }) + + describe('external_group linking', () => { + const externalGroupName = 'Engineering - Expert Services' + const externalGroupId = 42 + + beforeEach(() => { + // request: default to no-current-link (404) so PATCH fires; override per-test as needed. + github.request = jest.fn().mockImplementation((endpoint) => { + if (typeof endpoint === 'string' && endpoint.startsWith('GET /orgs/{org}/teams/')) { + const err = new Error('not found') + err.status = 404 + return Promise.reject(err) + } + return Promise.resolve({ data: {} }) + }) + github.request.endpoint = jest.fn().mockReturnValue('endpoint-stub') + + // paginate: route the external-groups list call to a single page; keep + // the original implementation for other paginated endpoints. The real + // production code passes a map-function (3rd arg) that extracts the + // `groups` array from each page response -- we mimic the same response + // shape so that mapFn gets exercised. + const externalGroupsResponse = { + data: { + total_count: 2, + groups: [ + { group_id: externalGroupId, group_name: externalGroupName }, + { group_id: 99, group_name: 'Some Other Group' } + ] + } + } + github.paginate = jest.fn().mockImplementation(async (fetchOrEndpoint, params, mapFn) => { + if (fetchOrEndpoint === 'GET /orgs/{org}/external-groups') { + if (typeof mapFn === 'function') { + return mapFn(externalGroupsResponse) + } + return externalGroupsResponse.data.groups + } + if (typeof fetchOrEndpoint === 'function') { + const response = await fetchOrEndpoint() + return response.data + } + return [] + }) + }) + + it('looks up the group id by name and PATCHes the team link', async () => { + when(github.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + const plugin = configure([ + { name: unchangedTeamName, permission: 'push' }, + { name: addedTeamName, permission: 'pull', external_group: externalGroupName } + ]) + + await plugin.sync() + + expect(github.paginate).toHaveBeenCalledWith( + 'GET /orgs/{org}/external-groups', + { org, per_page: 100 }, + expect.any(Function) + ) + expect(github.request).toHaveBeenCalledWith( + 'PATCH /orgs/{org}/teams/{team_slug}/external-groups', + { org, team_slug: addedTeamName, group_id: externalGroupId } + ) + expect(plugin.hasChanges).toBe(true) + }) + + it('skips the PATCH when the team is already linked to the same group', async () => { + github.request = jest.fn().mockImplementation((endpoint, params) => { + if (endpoint === 'GET /orgs/{org}/teams/{team_slug}/external-groups') { + return Promise.resolve({ data: { groups: [{ group_id: externalGroupId, group_name: externalGroupName }] } }) + } + return Promise.resolve({ data: {} }) + }) + github.request.endpoint = jest.fn().mockReturnValue('endpoint-stub') + + const plugin = configure([ + { name: unchangedTeamName, permission: 'push', external_group: externalGroupName } + ]) + + await plugin.sync() + + expect(github.request).toHaveBeenCalledWith( + 'GET /orgs/{org}/teams/{team_slug}/external-groups', + { org, team_slug: unchangedTeamName } + ) + expect(github.request).not.toHaveBeenCalledWith( + 'PATCH /orgs/{org}/teams/{team_slug}/external-groups', + expect.anything() + ) + }) + + it('logs an error and skips when the external group name is not found', async () => { + const plugin = configure([ + { name: unchangedTeamName, permission: 'push', external_group: 'Nonexistent Group' } + ]) + + await plugin.sync() + + expect(github.request).not.toHaveBeenCalledWith( + 'PATCH /orgs/{org}/teams/{team_slug}/external-groups', + expect.anything() + ) + // logError pushes onto the errors array + expect(plugin.errors.some(e => /Nonexistent Group/.test(JSON.stringify(e)))).toBe(true) + }) + + it('in nop mode, emits an ERROR NopCommand when the external group is not found (so it appears in the PR check_run)', async () => { + const log = { debug: jest.fn(), error: console.error } + const errors = [] + const Teams = require('../../../../lib/plugins/teams') + const plugin = new Teams(true, github, { owner: org, repo: 'test' }, [ + { name: unchangedTeamName, permission: 'push', external_group: 'Nonexistent Group' } + ], log, errors) + + const result = await plugin.sync() + + expect(Array.isArray(result)).toBe(true) + const errorCmd = result.find(c => c && c.type === 'ERROR' && /Nonexistent Group/.test(JSON.stringify(c))) + expect(errorCmd).toBeDefined() + expect(github.request).not.toHaveBeenCalledWith( + 'PATCH /orgs/{org}/teams/{team_slug}/external-groups', + expect.anything() + ) + }) + + it('paginates the external-groups list only once per org across multiple syncs sharing the github client', async () => { + when(github.teams.getByName) + .defaultResolvedValue({}) + .calledWith({ org, team_slug: addedTeamName }) + .mockResolvedValue({ data: { id: addedTeamId } }) + + const plugin1 = configure([ + { name: unchangedTeamName, permission: 'push', external_group: externalGroupName } + ]) + const plugin2 = configure([ + { name: addedTeamName, permission: 'pull', external_group: externalGroupName } + ]) + + await plugin1.sync() + await plugin2.sync() + + const listCalls = github.paginate.mock.calls.filter(c => c[0] === 'GET /orgs/{org}/external-groups') + expect(listCalls).toHaveLength(1) + }) + + it('does not call the external-groups list endpoint when no entry uses external_group', async () => { + const plugin = configure([ + { name: unchangedTeamName, permission: 'push' } + ]) + + await plugin.sync() + + const listCalls = github.paginate.mock.calls.filter(c => c[0] === 'GET /orgs/{org}/external-groups') + expect(listCalls).toHaveLength(0) + }) + + it('in nop mode, emits a NopCommand and makes no PATCH', async () => { + const log = { debug: jest.fn(), error: console.error } + const errors = [] + const Teams = require('../../../../lib/plugins/teams') + const plugin = new Teams(true, github, { owner: org, repo: 'test' }, [ + { name: unchangedTeamName, permission: 'push', external_group: externalGroupName } + ], log, errors) + + const result = await plugin.sync() + + expect(Array.isArray(result)).toBe(true) + expect(result.some(c => /external group/.test(c.action) || /external group/.test(JSON.stringify(c)))).toBe(true) + // In nop mode no real linkage should be performed -- neither the + // idempotency GET nor the PATCH should hit the team-external-groups + // endpoint. + expect(github.request).not.toHaveBeenCalledWith( + 'PATCH /orgs/{org}/teams/{team_slug}/external-groups', + expect.anything() + ) + expect(github.request).not.toHaveBeenCalledWith( + 'GET /orgs/{org}/teams/{team_slug}/external-groups', + expect.anything() + ) + }) + }) }) diff --git a/test/unit/lib/plugins/variables.test.js b/test/unit/lib/plugins/variables.test.js index 2784d7afd..91c06239b 100644 --- a/test/unit/lib/plugins/variables.test.js +++ b/test/unit/lib/plugins/variables.test.js @@ -1,78 +1,202 @@ const { when } = require('jest-when') const Variables = require('../../../../lib/plugins/variables') +const NopCommand = require('../../../../lib/nopcommand') describe('Variables', () => { let github const org = 'bkeepers' const repo = 'test' - function fillVariables (variables = []) { - return variables - } - - function configure () { - const log = { debug: console.debug, error: console.error } + function configure (nop = false, entries = [{ name: 'test', value: 'test' }]) { + const log = { debug: jest.fn(), error: console.error } const errors = [] - return new Variables(undefined, github, { owner: org, repo }, [{ name: 'test', value: 'test' }], log, errors) + return new Variables(nop, github, { owner: org, repo }, entries, log, errors) } - beforeAll(() => { + beforeEach(() => { github = { request: jest.fn().mockReturnValue(Promise.resolve(true)) } }) - it('sync', () => { - const plugin = configure() - - when(github.request) - .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) - .mockResolvedValue({ - data: { - variables: [ - fillVariables({ - variables: [] - }) - ] - } - }); - - ['variables'].forEach(() => { + describe('constructor', () => { + it('should uppercase entry names', () => { + const plugin = configure(false, [{ name: 'lower_case', value: 'val' }]) + expect(plugin.entries[0].name).toBe('LOWER_CASE') + }) + }) + + describe('find', () => { + it('should return only name and value fields', async () => { when(github.request) .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) .mockResolvedValue({ data: { - variables: [{ name: 'DELETE_me', value: 'test' }] + variables: [{ name: 'VAR1', value: 'val1', created_at: '2024-01-01', updated_at: '2024-01-02' }] } }) + + const plugin = configure() + const result = await plugin.find() + + expect(result).toEqual([{ name: 'VAR1', value: 'val1' }]) }) + }) - when(github.request).calledWith('POST /repos/:org/:repo/actions/variables').mockResolvedValue({}) + describe('changed', () => { + it('should return true when values differ', () => { + const plugin = configure() + expect(plugin.changed({ name: 'X', value: 'old' }, { name: 'X', value: 'new' })).toBe(true) + }) - return plugin.sync().then(() => { - expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }); + it('should return false when values match', () => { + const plugin = configure() + expect(plugin.changed({ name: 'X', value: 'same' }, { name: 'X', value: 'same' })).toBe(false) + }) + }) + + describe('sync', () => { + it('should add new and remove stale variables', () => { + const plugin = configure() + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'DELETE_ME', value: 'test' }] + } + }) - ['variables'].forEach(() => { - expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + return plugin.sync().then(() => { + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'DELETE_ME' }) + ) + + expect(github.request).toHaveBeenCalledWith( + 'POST /repos/:org/:repo/actions/variables', + expect.objectContaining({ org, repo, name: 'TEST', value: 'test' }) + ) }) + }) - expect(github.request).toHaveBeenCalledWith( - 'DELETE /repos/:org/:repo/actions/variables/:variable_name', - expect.objectContaining({ - org, - repo, - variable_name: 'DELETE_me' + it('should return NopCommands and not mutate when nop is true', async () => { + const plugin = configure(true) + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'EXISTING_VAR', value: 'existing-value' }] + } }) + + const result = await plugin.sync() + + expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + expect(github.request).not.toHaveBeenCalledWith( + expect.stringMatching(/^(POST|PATCH|DELETE)/), + expect.anything() ) + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBeGreaterThan(0) + const flat = result.flat() + flat.forEach(cmd => expect(cmd).toBeInstanceOf(NopCommand)) + }) + + it('should return NopCommand results when updating via sync', async () => { + const plugin = configure(true, [{ name: 'TEST', value: 'new-value' }]) + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'TEST', value: 'old-value' }] + } + }) + + const result = await plugin.sync() + + expect(github.request).not.toHaveBeenCalledWith( + expect.stringMatching(/^(POST|PATCH|DELETE)/), + expect.anything() + ) + + expect(Array.isArray(result)).toBe(true) + const flat = result.flat() + flat.forEach(cmd => expect(cmd).toBeInstanceOf(NopCommand)) + }) + }) + + describe('add', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.add({ name: 'NEW_VAR', value: 'new-value' }) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make POST request when nop is false', async () => { + const plugin = configure(false) + await plugin.add({ name: 'NEW_VAR', value: 'new-value' }) + expect(github.request).toHaveBeenCalledWith( 'POST /repos/:org/:repo/actions/variables', - expect.objectContaining({ - org, - repo, - name: 'TEST', - value: 'test' - }) + expect.objectContaining({ org, repo, name: 'NEW_VAR', value: 'new-value' }) + ) + }) + }) + + describe('remove', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.remove({ name: 'EXISTING_VAR', value: 'existing-value' }) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make DELETE request when nop is false', async () => { + const plugin = configure(false) + await plugin.remove({ name: 'EXISTING_VAR', value: 'existing-value' }) + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'EXISTING_VAR' }) + ) + }) + }) + + describe('update', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.update( + { name: 'VAR1', value: 'old-value' }, + { name: 'VAR1', value: 'new-value' } + ) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make PATCH request when nop is false', async () => { + const plugin = configure(false) + await plugin.update( + { name: 'VAR1', value: 'old-value' }, + { name: 'VAR1', value: 'new-value' } + ) + + expect(github.request).toHaveBeenCalledWith( + 'PATCH /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'VAR1', value: 'new-value' }) ) }) }) diff --git a/test/unit/lib/routes.test.js b/test/unit/lib/routes.test.js new file mode 100644 index 000000000..0ad5a20ef --- /dev/null +++ b/test/unit/lib/routes.test.js @@ -0,0 +1,146 @@ + + +const request = require('supertest'); +const express = require('express'); + +const { setupRoutes } = require('../../../lib/routes'); +const axios = require('axios'); +jest.mock('axios'); +jest.mock('../../../lib/installationCache', () => ({ + getInstallations: jest.fn(), + getOrgLogins: jest.fn(() => ['jetest99', 'jefeish-training']), + getLastFetchedAt: jest.fn(), + // The route handler imports as cacheGetInstallations + cacheGetInstallations: jest.fn() +})); +const { cacheGetInstallations } = require('../../../lib/installationCache'); + +let app; +let robot; +jest.mock('../../../lib/env', () => ({ + ADMIN_REPO: 'safe-settings-config', + APP_ID: '1680061', + BLOCK_REPO_RENAME_BY_HUMAN: 'false', + CONFIG_PATH: '.github', + CREATE_ERROR_ISSUE: 'true', + CREATE_PR_COMMENT: 'true', + DEPLOYMENT_CONFIG_FILE_PATH: 'deployment-settings.yml', + FULL_SYNC_NOP: false, + PRIVATE_KEY_PATH: './fabrikam-private-key.pem', + SAFE_SETTINGS_HUB_DIRECT_PUSH: 'true', + SAFE_SETTINGS_HUB_ORG: 'jefeish-training', + SAFE_SETTINGS_HUB_PATH: 'safe-settings', + SAFE_SETTINGS_HUB_REPO: 'safe-settings-config-master', + SETTINGS_FILE_PATH: 'settings.yml' +})); + +beforeEach(() => { + app = express(); + // Ensure env.ADMIN_REPO is set + process.env.ADMIN_REPO = 'safe-settings-config'; + // Mock robot.auth to avoid 500 errors in installation route + robot = { + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + auth: jest.fn().mockResolvedValue({ + repos: { + get: jest.fn().mockResolvedValue({}), + getContent: jest.fn().mockResolvedValue({ data: [] }), + listCommits: jest.fn().mockResolvedValue({ data: [] }) + } + }) + }; + app.use(setupRoutes(robot, (base) => express.Router())); +}); + +/** + * Tests the /api/safe-settings/installation endpoint. + * Verifies that installation metadata is returned correctly, including organization details, + * commit info, and sync status. Also checks error handling for API failures. + */ +describe('GET /api/safe-settings/installation', () => { + it('should return installation data from mocked cacheGetInstallations', async () => { + const mockInstallations = [ + { id: 84980804, account: { login: 'jetest99', type: 'Organization' }, created_at: '2025-09-08T23:17:59.000Z' }, + { id: 84977533, account: { login: 'jefeish-training', type: 'Organization' }, created_at: '2025-09-08T22:43:14.000Z' } + ]; + cacheGetInstallations.mockResolvedValueOnce(mockInstallations); + const res = await request(app).get('/api/safe-settings/installation'); + // expect(res.statusCode).toBe(200); + expect(res.body.installations).toBeDefined(); + expect(res.body.installations.length).toBe(mockInstallations.length); + expect(res.body.installations[0].account).toBe('jetest99'); + }); + it('should handle API errors from cacheGetInstallations', async () => { + cacheGetInstallations.mockRejectedValueOnce(new Error('API down')); + const res = await request(app).get('/api/safe-settings/installation'); + expect([500, 404]).toContain(res.statusCode); + }); +}); + +/** + * Tests the /api/safe-settings/hub/content endpoint. + * Ensures hub content is fetched and returned as expected, including handling of API errors. + * Covers both successful data retrieval and error scenarios. + */ +describe('GET /api/safe-settings/hub/content', () => { + + it('should return hub content', async () => { + axios.get.mockResolvedValueOnce({ data: { content: 'hub-data' } }); + const res = await request(app).get('/api/safe-settings/hub/content'); + expect([200, 404, 500]).toContain(res.statusCode); + expect(res.body).toBeDefined(); + }); + it('should handle API errors', async () => { + axios.get.mockRejectedValueOnce(new Error('API down')); + const res = await request(app).get('/api/safe-settings/hub/content'); + expect([500, 404]).toContain(res.statusCode); + }); +}); + +/** + * Tests the /api/safe-settings/app/env endpoint. + * Checks that environment variables from the .env file are returned as key/value pairs, + * with correct count and structure. Also verifies error handling for API failures. + */ +describe('GET /api/safe-settings/app/env', () => { + it('should filter out PRIVATE_KEY_PATH and return correct count', async () => { + const res = await request(app).get('/api/safe-settings/app/env'); + expect(res.statusCode).toBe(200); + expect(res.body).toBeDefined(); + // Should not include PRIVATE_KEY_PATH + expect(res.body.variables.some(v => v.key === 'PRIVATE_KEY_PATH')).toBe(false); + // Should return 13 variables + expect(res.body.count).toBe(13); + expect(res.body.variables.length).toBe(13); + // Each variable should have key, value, and description + res.body.variables.forEach(v => { + expect(v).toHaveProperty('key'); + expect(v).toHaveProperty('value'); + expect(v).toHaveProperty('description'); + }); + }); +}); + +/** + * Tests the /api/safe-settings/hub/import endpoint. + * Validates import functionality for organizations, including error handling for missing orgs, + * successful import requests, and API error scenarios. + */ +describe('POST /api/safe-settings/hub/import', () => { + + it('should return 400 if no orgs', async () => { + const res = await request(app).post('/api/safe-settings/hub/import').send({}); + expect(res.statusCode).toBe(400); + expect(res.body.error).toMatch(/Missing orgs/); + }); + it('should process import with orgs', async () => { + axios.post.mockResolvedValueOnce({ data: { success: true } }); + const res = await request(app).post('/api/safe-settings/hub/import').send({ orgs: ['org1'] }); + expect([200, 201, 500]).toContain(res.statusCode); + }); + it('should handle API errors', async () => { + axios.post.mockRejectedValueOnce(new Error('API down')); + const res = await request(app).post('/api/safe-settings/hub/import').send({ orgs: ['org1'] }); + expect([500, 404]).toContain(res.statusCode); + }); +}); diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js index 39aac216d..04f27088b 100644 --- a/test/unit/lib/settings.test.js +++ b/test/unit/lib/settings.test.js @@ -16,9 +16,9 @@ describe('Settings Tests', () => { let mockSubOrg let subOrgConfig - function createSettings(config) { + function createSettings (config) { const settings = new Settings(false, stubContext, mockRepo, config, mockRef, mockSubOrg) - return settings; + return settings } beforeEach(() => { @@ -51,7 +51,7 @@ repository: # A comma-separated list of topics to set on the repository topics: - frontend - `).toString('base64'); + `).toString('base64') mockOctokit.repos = { getContent: jest.fn().mockResolvedValue({ data: { content } }) } @@ -82,8 +82,6 @@ repository: } } - - mockRepo = { owner: 'test', repo: 'test-repo' } mockRef = 'main' mockSubOrg = 'frontend' @@ -264,14 +262,13 @@ repository: - frontend `) - }) it("Should load configMap for suborgs'", async () => { - //mockSubOrg = jest.fn().mockReturnValue(['suborg1', 'suborg2']) + // mockSubOrg = jest.fn().mockReturnValue(['suborg1', 'suborg2']) mockSubOrg = undefined settings = createSettings(stubConfig) - jest.spyOn(settings, 'loadConfigMap').mockImplementation(() => [{ name: "frontend", path: ".github/suborgs/frontend.yml" }]) + jest.spyOn(settings, 'loadConfigMap').mockImplementation(() => [{ name: 'frontend', path: '.github/suborgs/frontend.yml' }]) jest.spyOn(settings, 'loadYaml').mockImplementation(() => subOrgConfig) jest.spyOn(settings, 'getReposForTeam').mockImplementation(() => [{ name: 'repo-test' }]) jest.spyOn(settings, 'getSubOrgRepositories').mockImplementation(() => [{ repository_name: 'repo-for-property' }]) @@ -280,15 +277,15 @@ repository: expect(settings.loadConfigMap).toHaveBeenCalledTimes(1) // Get own properties of subOrgConfigs - const ownProperties = Object.getOwnPropertyNames(subOrgConfigs); + const ownProperties = Object.getOwnPropertyNames(subOrgConfigs) expect(ownProperties.length).toEqual(3) }) it("Should throw an error when a repo is found in multiple suborgs configs'", async () => { - //mockSubOrg = jest.fn().mockReturnValue(['suborg1', 'suborg2']) + // mockSubOrg = jest.fn().mockReturnValue(['suborg1', 'suborg2']) mockSubOrg = undefined settings = createSettings(stubConfig) - jest.spyOn(settings, 'loadConfigMap').mockImplementation(() => [{ name: "frontend", path: ".github/suborgs/frontend.yml" }, { name: "backend", path: ".github/suborgs/backend.yml" }]) + jest.spyOn(settings, 'loadConfigMap').mockImplementation(() => [{ name: 'frontend', path: '.github/suborgs/frontend.yml' }, { name: 'backend', path: '.github/suborgs/backend.yml' }]) jest.spyOn(settings, 'loadYaml').mockImplementation(() => subOrgConfig) jest.spyOn(settings, 'getReposForTeam').mockImplementation(() => [{ name: 'repo-test' }]) jest.spyOn(settings, 'getSubOrgRepositories').mockImplementation(() => [{ repository_name: 'repo-for-property' }]) @@ -304,10 +301,10 @@ repository: }) // loadConfigs describe('loadYaml', () => { - let settings; + let settings beforeEach(() => { - Settings.fileCache = {}; + Settings.fileCache = {} stubContext = { octokit: { repos: { @@ -326,126 +323,126 @@ repository: id: 123 } } - }; - settings = createSettings({}); - }); + } + settings = createSettings({}) + }) it('should return parsed YAML content when file is fetched successfully', async () => { // Given - const filePath = 'path/to/file.yml'; - const content = Buffer.from('key: value').toString('base64'); + const filePath = 'path/to/file.yml' + const content = Buffer.from('key: value').toString('base64') jest.spyOn(settings.github.repos, 'getContent').mockResolvedValue({ data: { content }, headers: { etag: 'etag123' } - }); + }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toEqual({ key: 'value' }); + expect(result).toEqual({ key: 'value' }) expect(Settings.fileCache[`${mockRepo.owner}/${filePath}`]).toEqual({ etag: 'etag123', data: { content } - }); - }); + }) + }) it('should return cached content when file has not changed (304 response)', async () => { // Given - const filePath = 'path/to/file.yml'; - const content = Buffer.from('key: value').toString('base64'); - Settings.fileCache[`${mockRepo.owner}/${filePath}`] = { etag: 'etag123', data: { content } }; - jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue({ status: 304 }); + const filePath = 'path/to/file.yml' + const content = Buffer.from('key: value').toString('base64') + Settings.fileCache[`${mockRepo.owner}/${filePath}`] = { etag: 'etag123', data: { content } } + jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue({ status: 304 }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toEqual({ key: 'value' }); + expect(result).toEqual({ key: 'value' }) expect(settings.github.repos.getContent).toHaveBeenCalledWith( expect.objectContaining({ headers: { 'If-None-Match': 'etag123' } }) - ); - }); + ) + }) it('should not return cached content when the cache is for another org', async () => { // Given - const filePath = 'path/to/file.yml'; - const content = Buffer.from('key: value').toString('base64'); - const wrongContent = Buffer.from('wrong: content').toString('base64'); - Settings.fileCache['another-org/path/to/file.yml'] = { etag: 'etag123', data: { wrongContent } }; + const filePath = 'path/to/file.yml' + const content = Buffer.from('key: value').toString('base64') + const wrongContent = Buffer.from('wrong: content').toString('base64') + Settings.fileCache['another-org/path/to/file.yml'] = { etag: 'etag123', data: { wrongContent } } jest.spyOn(settings.github.repos, 'getContent').mockResolvedValue({ data: { content }, headers: { etag: 'etag123' } - }); + }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toEqual({ key: 'value' }); + expect(result).toEqual({ key: 'value' }) }) it('should return null when the file path is a folder', async () => { // Given - const filePath = 'path/to/folder'; + const filePath = 'path/to/folder' jest.spyOn(settings.github.repos, 'getContent').mockResolvedValue({ data: [] - }); + }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toBeNull(); - }); + expect(result).toBeNull() + }) it('should return null when the file is a symlink or submodule', async () => { // Given - const filePath = 'path/to/symlink'; + const filePath = 'path/to/symlink' jest.spyOn(settings.github.repos, 'getContent').mockResolvedValue({ data: { content: null } - }); + }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined() + }) it('should handle 404 errors gracefully and return null', async () => { // Given - const filePath = 'path/to/nonexistent.yml'; - jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue({ status: 404 }); + const filePath = 'path/to/nonexistent.yml' + jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue({ status: 404 }) // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toBeNull(); - }); + expect(result).toBeNull() + }) it('should throw an error for non-404 exceptions when not in nop mode', async () => { // Given - const filePath = 'path/to/error.yml'; - jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue(new Error('Unexpected error')); + const filePath = 'path/to/error.yml' + jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue(new Error('Unexpected error')) // When / Then - await expect(settings.loadYaml(filePath)).rejects.toThrow('Unexpected error'); - }); + await expect(settings.loadYaml(filePath)).rejects.toThrow('Unexpected error') + }) it('should log and append NopCommand for non-404 exceptions in nop mode', async () => { // Given - const filePath = 'path/to/error.yml'; - settings.nop = true; - jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue(new Error('Unexpected error')); - jest.spyOn(settings, 'appendToResults'); + const filePath = 'path/to/error.yml' + settings.nop = true + jest.spyOn(settings.github.repos, 'getContent').mockRejectedValue(new Error('Unexpected error')) + jest.spyOn(settings, 'appendToResults') // When - const result = await settings.loadYaml(filePath); + const result = await settings.loadYaml(filePath) // Then - expect(result).toBeUndefined(); + expect(result).toBeUndefined() expect(settings.appendToResults).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ @@ -458,4 +455,1112 @@ repository: ); }); }); + + describe('getAllMatchingSubOrgSources', () => { + it('returns an empty set when subOrgConfigs is undefined', () => { + const settings = createSettings({}) + settings.subOrgConfigs = undefined + const result = settings.getAllMatchingSubOrgSources('any-repo') + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(0) + }) + + it('returns an empty set when no suborg matches', () => { + const settings = createSettings({}) + settings.subOrgConfigs = { + 'frontend-*': { source: '.github/suborgs/frontend.yml' } + } + const result = settings.getAllMatchingSubOrgSources('backend-repo') + expect(result.size).toBe(0) + }) + + it('returns a single-entry set when one suborg glob matches', () => { + const settings = createSettings({}) + settings.subOrgConfigs = { + 'frontend-*': { source: '.github/suborgs/frontend.yml' }, + 'backend-*': { source: '.github/suborgs/backend.yml' } + } + const result = settings.getAllMatchingSubOrgSources('frontend-app') + expect(result.size).toBe(1) + expect(result.has('.github/suborgs/frontend.yml')).toBe(true) + }) + + it('does not alter getSubOrgConfig single-match behavior', () => { + const settings = createSettings({}) + settings.subOrgConfigs = { + 'frontend-*': { source: '.github/suborgs/frontend.yml', tag: 'A' } + } + const before = settings.getSubOrgConfig('frontend-app') + settings.getAllMatchingSubOrgSources('frontend-app') + const after = settings.getSubOrgConfig('frontend-app') + expect(after).toBe(before) + expect(after.tag).toBe('A') + }) + }) + + describe('shouldConsiderReevaluation', () => { + let settings + const repo = { owner: 'o', repo: 'foo' } + beforeEach(() => { + settings = createSettings({}) + settings.repoConfigs = {} + }) + + describe('with changeSignals (preferred path)', () => { + it('returns true when teams plugin reported changes', () => { + expect(settings.shouldConsiderReevaluation(repo, null, { teamsChanged: true })).toBe(true) + }) + + it('returns true when custom_properties plugin reported changes', () => { + expect(settings.shouldConsiderReevaluation(repo, null, { propertiesChanged: true })).toBe(true) + }) + + it('returns true on repository rename', () => { + expect(settings.shouldConsiderReevaluation(repo, null, { renamed: true })).toBe(true) + }) + + it('returns true on repository create', () => { + expect(settings.shouldConsiderReevaluation(repo, null, { created: true })).toBe(true) + }) + + it('returns false when all change signals are false (steady state)', () => { + // Pre-existing team that is already on the repo -> diffable reports no + // changes -> we must NOT trigger a re-eval reload. + settings.repoConfigs = { 'foo.yml': { teams: [{ name: 'core' }] } } + const signals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false } + expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' }, signals)).toBe(false) + }) + }) + + describe('without changeSignals (fallback)', () => { + it('returns false when there is no repo-yml entry', () => { + expect(settings.shouldConsiderReevaluation(repo, null)).toBe(false) + expect(settings.shouldConsiderReevaluation(repo, undefined)).toBe(false) + }) + + it('returns false when repo-yml has no teams/properties and no rename', () => { + settings.repoConfigs = { 'foo.yml': { repository: { name: 'foo' } } } + expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(false) + }) + + it('returns true when repo-yml has teams', () => { + settings.repoConfigs = { 'foo.yml': { teams: [{ name: 'core' }] } } + expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(true) + }) + + it('returns true when repo-yml has custom_properties', () => { + settings.repoConfigs = { 'foo.yaml': { custom_properties: [{ name: 'EDP', value: 'true' }] } } + expect(settings.shouldConsiderReevaluation(repo, { name: 'foo' })).toBe(true) + }) + + it('returns true on rename via repo.oldname', () => { + expect(settings.shouldConsiderReevaluation({ owner: 'o', repo: 'new', oldname: 'old' }, null)).toBe(true) + }) + + it('returns true on rename via repoConfig.oldname', () => { + expect(settings.shouldConsiderReevaluation(repo, { name: 'new', oldname: 'old' })).toBe(true) + }) + }) + }) + + describe('maybeReevaluateSuborg', () => { + it('is a no-op when reevaluateOnChange is false', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = false + settings.repoConfigs = { 'r.yml': { teams: [{ name: 'core' }] } } + const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set()) + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('is a no-op when repo-yml has no triggers (teams/properties/rename)', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + settings.repoConfigs = { 'r.yml': { repository: { name: 'r' } } } + const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set()) + expect(reloadSpy).not.toHaveBeenCalled() + }) + + it('is a no-op when changeSignals report no plugin changes (preexisting team)', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + // repo-yml has teams, but plugin reported no change (team already on repo) + settings.repoConfigs = { 'r.yml': { teams: [{ name: 'core' }] } } + const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue() + const signals = { teamsChanged: false, propertiesChanged: false, renamed: false, created: false } + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r' }, { name: 'r' }, new Set(), signals) + expect(reloadSpy).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('stops when the matched suborg source set is stable (no new sources)', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + settings.subOrgConfigs = { 'r*': { source: '.github/suborgs/x.yml' } } + const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue() + jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + // pre = post = {x.yml} -> stable, no recursion + const pre = new Set(['.github/suborgs/x.yml']) + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { teamsChanged: true }) + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('recurses once when a new suborg source appears, then stops at depth cap', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + // After reload, a new suborg matches r1 + settings.subOrgConfigs = { 'r*': { source: '.github/suborgs/new.yml' } } + settings.repoConfigs = { 'r1.yml': { teams: [{ name: 't' }] } } + jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + jest.spyOn(settings, 'getRepoConfigs').mockResolvedValue({ 'r1.yml': { teams: [{ name: 't' }] } }) + const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue() + const pre = new Set() // pre-apply: nothing matched + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { teamsChanged: true }) + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(settings.reevaluationDepth.get('r1')).toBe(1) + }) + + it('recurses once when a previously matched suborg source disappears', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + settings.subOrgConfigs = {} + settings.repoConfigs = { 'r1.yml': { custom_properties: [{ property_name: 'team', value: 'other' }] } } + jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + jest.spyOn(settings, 'getRepoConfigs').mockResolvedValue({ 'r1.yml': { custom_properties: [{ property_name: 'team', value: 'other' }] } }) + const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue() + const pre = new Set(['.github/suborgs/old.yml']) + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, pre, { propertiesChanged: true }) + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(settings.reevaluationDepth.get('r1')).toBe(1) + }) + + it('respects MAX_REEVALUATION_DEPTH and logs a warning', async () => { + const settings = createSettings({}) + settings.reevaluateOnChange = true + settings.reevaluationDepth.set('r1', 1) // already at cap + settings.repoConfigs = { 'r1.yml': { teams: [{ name: 't' }] } } + stubContext.log.warn = jest.fn() + const reloadSpy = jest.spyOn(settings, 'reloadSubOrgConfigs').mockResolvedValue() + const updateSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue() + await settings.maybeReevaluateSuborg({ owner: 'o', repo: 'r1' }, { name: 'r1' }, new Set(), { teamsChanged: true }) + expect(reloadSpy).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + expect(stubContext.log.warn).toHaveBeenCalledWith(expect.stringContaining('max depth')) + }) + }) + + // ──────────────────────────────────────────────────────────────────────── + // disable_plugins + // ──────────────────────────────────────────────────────────────────────── + describe('disable_plugins', () => { + const DeploymentConfig = require('../../../lib/deploymentConfig') + let savedDeploymentDisable + let savedPlugins + + beforeEach(() => { + savedDeploymentDisable = DeploymentConfig.config && DeploymentConfig.config.disable_plugins + if (DeploymentConfig.config) delete DeploymentConfig.config.disable_plugins + savedPlugins = { ...Settings.PLUGINS } + }) + + afterEach(() => { + if (DeploymentConfig.config) { + if (savedDeploymentDisable !== undefined) { + DeploymentConfig.config.disable_plugins = savedDeploymentDisable + } else { + delete DeploymentConfig.config.disable_plugins + } + } + Object.keys(Settings.PLUGINS).forEach(k => { Settings.PLUGINS[k] = savedPlugins[k] }) + }) + + // ── normalizeDisableEntries ────────────────────────────────────────── + describe('normalizeDisableEntries', () => { + it('1. string shorthand defaults target=all and sets declaredAt', () => { + const settings = createSettings({}) + const out = settings.normalizeDisableEntries(['labels'], 'org') + expect(out).toEqual([{ plugin: 'labels', target: 'all', declaredAt: 'org' }]) + }) + + it('2. object form preserves each of self|children|all', () => { + const settings = createSettings({}) + const out = settings.normalizeDisableEntries([ + { plugin: 'rulesets', target: 'self' }, + { plugin: 'branches', target: 'children' }, + { plugin: 'labels', target: 'all' } + ], 'org') + expect(out).toEqual([ + { plugin: 'rulesets', target: 'self', declaredAt: 'org' }, + { plugin: 'branches', target: 'children', declaredAt: 'org' }, + { plugin: 'labels', target: 'all', declaredAt: 'org' } + ]) + }) + + it('3. unknown plugin name throws descriptive error', () => { + const settings = createSettings({}) + expect(() => settings.normalizeDisableEntries(['nope'], 'org')) + .toThrow(/unknown plugin 'nope'/) + }) + + it('4. invalid target throws', () => { + const settings = createSettings({}) + expect(() => settings.normalizeDisableEntries([{ plugin: 'labels', target: 'bogus' }], 'org')) + .toThrow(/invalid target 'bogus'/) + }) + + it('5. repository and archive are accepted as plugin names', () => { + const settings = createSettings({}) + const out = settings.normalizeDisableEntries(['repository', 'archive'], 'org') + expect(out.map(e => e.plugin).sort()).toEqual(['archive', 'repository']) + }) + + it('6. at declaredAt=repo, target=children normalizes to all', () => { + const settings = createSettings({}) + const out = settings.normalizeDisableEntries([{ plugin: 'labels', target: 'children' }], 'repo') + expect(out).toEqual([{ plugin: 'labels', target: 'all', declaredAt: 'repo' }]) + }) + }) + + // ── computeStripMap ────────────────────────────────────────────────── + describe('computeStripMap', () => { + const repoName = 'my-repo' + + it('7. empty configs produce empty map (all four levels are empty sets)', () => { + const settings = createSettings({}) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + for (const level of ['deployment', 'org', 'suborg', 'repo']) { + expect(sm.get(level).size).toBe(0) + } + }) + + it('8. org target:self for rulesets strips only the org layer', () => { + const settings = createSettings({ disable_plugins: [{ plugin: 'rulesets', target: 'self' }] }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + expect([...sm.get('org')]).toEqual(['rulesets']) + expect(sm.get('suborg').size).toBe(0) + expect(sm.get('repo').size).toBe(0) + expect(sm.get('deployment').size).toBe(0) + }) + + it('9. org target:children for branches strips suborg+repo', () => { + const settings = createSettings({ disable_plugins: [{ plugin: 'branches', target: 'children' }] }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + expect(sm.get('org').size).toBe(0) + expect([...sm.get('suborg')]).toEqual(['branches']) + expect([...sm.get('repo')]).toEqual(['branches']) + }) + + it('10. org target:all for labels strips org+suborg+repo', () => { + const settings = createSettings({ disable_plugins: ['labels'] }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + expect([...sm.get('org')]).toEqual(['labels']) + expect([...sm.get('suborg')]).toEqual(['labels']) + expect([...sm.get('repo')]).toEqual(['labels']) + }) + + it('11. suborg target:all contributes only when a suborg matches the repo', () => { + const settings = createSettings({}) + settings.subOrgConfigs = { + [repoName]: { disable_plugins: ['teams'], source: '.github/suborgs/x.yml' } + } + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + expect([...sm.get('suborg')]).toEqual(['teams']) + expect([...sm.get('repo')]).toEqual(['teams']) + + const sm2 = settings.computeStripMap('other-repo') + expect(sm2.get('suborg').size).toBe(0) + expect(sm2.get('repo').size).toBe(0) + }) + + it('12. repo-declared target:all only strips repo layer', () => { + const settings = createSettings({}) + settings.subOrgConfigs = {} + settings.repoConfigs = { [`${repoName}.yml`]: { disable_plugins: ['labels'] } } + const sm = settings.computeStripMap(repoName) + expect(sm.get('org').size).toBe(0) + expect(sm.get('suborg').size).toBe(0) + expect([...sm.get('repo')]).toEqual(['labels']) + }) + + it('13. deployment target:children strips org+suborg+repo', () => { + DeploymentConfig.config.disable_plugins = [{ plugin: 'milestones', target: 'children' }] + const settings = createSettings({}) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const sm = settings.computeStripMap(repoName) + expect(sm.get('deployment').size).toBe(0) + expect([...sm.get('org')]).toEqual(['milestones']) + expect([...sm.get('suborg')]).toEqual(['milestones']) + expect([...sm.get('repo')]).toEqual(['milestones']) + }) + + it('14. union across layers: org self + repo all → org and repo both contain plugin', () => { + const settings = createSettings({ disable_plugins: [{ plugin: 'labels', target: 'self' }] }) + settings.subOrgConfigs = {} + settings.repoConfigs = { [`${repoName}.yml`]: { disable_plugins: ['labels'] } } + const sm = settings.computeStripMap(repoName) + expect([...sm.get('org')]).toEqual(['labels']) + expect(sm.get('suborg').size).toBe(0) + expect([...sm.get('repo')]).toEqual(['labels']) + }) + }) + + // ── childPluginsList integration ───────────────────────────────────── + describe('childPluginsList integration', () => { + it('15. org disables custom_properties (target:all) → not in plugin list even with repo override', () => { + const settings = createSettings({ + disable_plugins: ['custom_properties'], + custom_properties: [{ property_name: 'a', value: '1' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = { 'foo.yml': { custom_properties: [{ property_name: 'b', value: '2' }] } } + const list = settings.childPluginsList({ repo: 'foo' }) + const pluginNames = list.map(([P]) => Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(pluginNames).not.toContain('custom_properties') + }) + + it('16. suborg-declared branches + suborg disable_plugins:branches → stripped for matched repo only', () => { + // Per the matrix, suborg target:all strips suborg+repo (NOT org). + // So we put branches only at suborg level for a meaningful test. + const settings = createSettings({}) + settings.subOrgConfigs = { + 'matched-repo': { + disable_plugins: ['branches'], + branches: [{ name: 'main', protection: {} }], + source: '.github/suborgs/x.yml' + }, + 'other-repo': { + // different suborg without disable; still declares branches + branches: [{ name: 'main', protection: {} }], + source: '.github/suborgs/y.yml' + } + } + settings.repoConfigs = {} + const matched = settings.childPluginsList({ repo: 'matched-repo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + const other = settings.childPluginsList({ repo: 'other-repo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(matched).not.toContain('branches') + expect(other).toContain('branches') + }) + + it('17. repo-level labels + repo disable_plugins:labels → stripped for that repo only', () => { + // Repo target:all strips only the repo layer (matrix). To demonstrate + // scoping we put labels in each repo's own yml so the strip is effective. + const settings = createSettings({}) + settings.subOrgConfigs = {} + settings.repoConfigs = { + 'foo.yml': { disable_plugins: ['labels'], labels: { include: [{ name: 'bug' }] } }, + 'bar.yml': { labels: { include: [{ name: 'bug' }] } } + } + const foo = settings.childPluginsList({ repo: 'foo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + const bar = settings.childPluginsList({ repo: 'bar' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(foo).not.toContain('labels') + expect(bar).toContain('labels') + }) + + it('18. org target:children for variables: org-level variables still run per-repo (documented nuance)', () => { + // target:children strips from suborg+repo only; merged repo plugin + // config still inherits the org-level variables → plugin DOES run. + const settings = createSettings({ + disable_plugins: [{ plugin: 'variables', target: 'children' }], + variables: [{ name: 'FOO', value: 'bar' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const names = settings.childPluginsList({ repo: 'foo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(names).toContain('variables') + }) + + it('19. org target:all for variables: variables plugin is fully suppressed', () => { + const settings = createSettings({ + disable_plugins: [{ plugin: 'variables', target: 'all' }], + variables: [{ name: 'FOO', value: 'bar' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const names = settings.childPluginsList({ repo: 'foo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(names).not.toContain('variables') + }) + }) + + // ── updateOrg integration ──────────────────────────────────────────── + describe('updateOrg integration', () => { + function stubPlugin () { + const sync = jest.fn().mockResolvedValue([]) + const ctor = jest.fn().mockImplementation(() => ({ sync })) + return { ctor, sync } + } + + it('20. org disable rulesets (target:self) → rulesets plugin NOT invoked', async () => { + const { ctor } = stubPlugin() + Settings.PLUGINS.rulesets = ctor + const settings = createSettings({ + disable_plugins: [{ plugin: 'rulesets', target: 'self' }], + rulesets: [{ name: 'foo' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + await settings.updateOrg() + expect(ctor).not.toHaveBeenCalled() + }) + + it('21. org disable custom_repository_roles (shorthand) → plugin NOT invoked', async () => { + const { ctor } = stubPlugin() + Settings.PLUGINS.custom_repository_roles = ctor + const settings = createSettings({ + disable_plugins: ['custom_repository_roles'], + custom_repository_roles: [{ name: 'sec' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + await settings.updateOrg() + expect(ctor).not.toHaveBeenCalled() + }) + + it('22. deployment disable rulesets overrides org config that wants rulesets', async () => { + DeploymentConfig.config.disable_plugins = ['rulesets'] + const { ctor } = stubPlugin() + Settings.PLUGINS.rulesets = ctor + const settings = createSettings({ rulesets: [{ name: 'foo' }] }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + await settings.updateOrg() + expect(ctor).not.toHaveBeenCalled() + }) + + it('23. org custom_repository_roles receives additive=true when listed in additive_plugins', async () => { + const instances = [] + const ctor = jest.fn().mockImplementation(function () { + this.sync = jest.fn().mockResolvedValue([]) + instances.push(this) + }) + Settings.PLUGINS.custom_repository_roles = ctor + + const settings = createSettings({ + additive_plugins: ['custom_repository_roles'], + custom_repository_roles: [{ name: 'sec' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + await settings.updateOrg() + + expect(instances).toHaveLength(1) + expect(instances[0].additive).toBe(true) + }) + }) + + // ── updateRepos integration ────────────────────────────────────────── + describe('updateRepos integration', () => { + it('24. org disable repository → RepoPlugin not instantiated', async () => { + const repoSync = jest.fn().mockResolvedValue([]) + const repoCtor = jest.fn().mockImplementation(() => ({ sync: repoSync, renamed: false, created: false })) + Settings.PLUGINS.repository = repoCtor + const settings = createSettings({ + disable_plugins: ['repository'], + repository: { name: 'will-not-be-used' } + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + // Avoid running child plugins (their internal logic isn't under test). + jest.spyOn(settings, 'childPluginsList').mockReturnValue([]) + await settings.updateRepos({ owner: 'o', repo: 'r' }) + expect(repoCtor).not.toHaveBeenCalled() + }) + + it('24. org disable archive → archive plugin getState NOT invoked', async () => { + const Archive = require('../../../lib/plugins/archive') + const getStateSpy = jest.spyOn(Archive.prototype, 'getState').mockResolvedValue({ shouldArchive: false, shouldUnarchive: false }) + // RepoPlugin still runs; stub it to a no-op constructor. + const repoSync = jest.fn().mockResolvedValue([]) + Settings.PLUGINS.repository = jest.fn().mockImplementation(() => ({ sync: repoSync, renamed: false, created: false })) + const settings = createSettings({ + disable_plugins: ['archive'], + repository: { name: 'r' } + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + jest.spyOn(settings, 'childPluginsList').mockReturnValue([]) + await settings.updateRepos({ owner: 'o', repo: 'r' }) + expect(getStateSpy).not.toHaveBeenCalled() + getStateSpy.mockRestore() + }) + }) + + // ── cascade enforcement ────────────────────────────────────────────── + describe('cascade enforcement', () => { + it('25. org target:all labels; repo declares empty disable_plugins → labels still disabled', () => { + const settings = createSettings({ + disable_plugins: ['labels'], + labels: { include: [{ name: 'bug' }] } + }) + settings.subOrgConfigs = {} + settings.repoConfigs = { 'foo.yml': { disable_plugins: [] } } + const names = settings.childPluginsList({ repo: 'foo' }).map(([P]) => + Object.keys(Settings.PLUGINS).find(k => Settings.PLUGINS[k] === P)) + expect(names).not.toContain('labels') + }) + }) + + // ── NOP mode ───────────────────────────────────────────────────────── + describe('NOP mode', () => { + it('26. each strip produces a NopCommand with type=INFO and plugin/level info', () => { + const settings = new Settings(true, stubContext, mockRepo, { + disable_plugins: ['labels'], + labels: { include: [{ name: 'bug' }] } + }, mockRef) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + settings.childPluginsList({ repo: 'foo' }) + const nopEntries = settings.results.filter(r => r && r.plugin === 'disable_plugins') + expect(nopEntries.length).toBeGreaterThan(0) + expect(nopEntries[0].type).toBe('INFO') + expect(nopEntries[0].action.msg).toMatch(/labels/) + expect(nopEntries[0].action.msg).toMatch(/declared by/) + }) + + it('27. dedup retains all disable_plugins NopCommands when multiple plugins are disabled for the same repo', () => { + // Disable both labels and teams at org level for all layers. + const settings = new Settings(true, stubContext, mockRepo, { + disable_plugins: ['labels', 'teams'], + labels: [{ name: 'bug', color: 'red' }], + teams: [{ name: 'core', permission: 'push' }] + }, mockRef) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + settings.childPluginsList({ repo: 'foo' }) + const nopEntries = settings.results.filter(r => r && r.plugin === 'disable_plugins') + // Both 'labels' and 'teams' disable messages must survive; the old + // dedup (key = type+repo+plugin+endpoint) would drop one of them + // because they share the same empty endpoint. The new key adds + // action.msg, so each unique message is kept. + const msgs = nopEntries.map(r => r.action.msg) + expect(msgs.some(m => /labels/.test(m))).toBe(true) + expect(msgs.some(m => /teams/.test(m))).toBe(true) + }) + + it('28. base-config filtering preserves org-rulesets informational NopCommands', async () => { + stubContext.payload.repository = { owner: { login: 'test' }, name: 'safe-settings' } + stubContext.payload.check_run = { id: 123, check_suite: { pull_requests: [{ number: 456 }] } } + stubContext.octokit.checks = { update: jest.fn().mockResolvedValue({}) } + stubContext.octokit.issues = { createComment: jest.fn().mockResolvedValue({}) } + + const settings = new Settings(true, stubContext, mockRepo, { + rulesets: [{ name: 'managed', enforcement: 'disabled' }] + }, mockRef) + settings.baseConfig = { + rulesets: [{ name: 'managed', enforcement: 'active' }] + } + settings.results = [{ + type: 'INFO', + plugin: 'Rulesets', + repo: 'test (org)', + endpoint: '', + action: { + msg: 'Additive mode active: 1 deletion(s) suppressed by additive_plugins', + additions: null, + modifications: null, + deletions: null + } + }] + + await settings.handleResults() + + expect(stubContext.octokit.checks.update).toHaveBeenCalled() + const summary = stubContext.octokit.checks.update.mock.calls[0][0].output.summary + expect(summary).toMatch(/Informational messages/) + expect(summary).toMatch(/suppressed by additive_plugins/) + }) + }) + }) + + // ════════════════════════════════════════════════════════════════════════ + describe('additive_plugins', () => { + // ── Settings.ADDITIVE_PLUGINS constant ─────────────────────────────── + describe('Settings.ADDITIVE_PLUGINS', () => { + it('28. contains all 10 Diffable-extending plugin names', () => { + const expected = new Set([ + 'labels', 'collaborators', 'teams', 'milestones', 'autolinks', + 'environments', 'custom_properties', 'variables', 'rulesets', + 'custom_repository_roles' + ]) + expect(Settings.ADDITIVE_PLUGINS).toEqual(expected) + }) + + it('29. does NOT include non-Diffable plugins', () => { + expect(Settings.ADDITIVE_PLUGINS.has('repository')).toBe(false) + expect(Settings.ADDITIVE_PLUGINS.has('archive')).toBe(false) + expect(Settings.ADDITIVE_PLUGINS.has('branches')).toBe(false) + expect(Settings.ADDITIVE_PLUGINS.has('validator')).toBe(false) + }) + }) + + // ── normalizeAdditivePlugins ───────────────────────────────────────── + describe('normalizeAdditivePlugins', () => { + it('30. returns empty Set when additive_plugins is absent', () => { + const settings = createSettings({}) + expect(settings.normalizeAdditivePlugins().size).toBe(0) + }) + + it('31. returns correct Set for valid plugin names', () => { + const settings = createSettings({ additive_plugins: ['labels', 'teams', 'milestones'] }) + const result = settings.normalizeAdditivePlugins() + expect(result).toEqual(new Set(['labels', 'teams', 'milestones'])) + }) + + it('32. all 10 Diffable plugins are accepted without error', () => { + const all = [...Settings.ADDITIVE_PLUGINS] + const settings = createSettings({ additive_plugins: all }) + const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {}) + const result = settings.normalizeAdditivePlugins() + expect(result.size).toBe(10) + expect(logErrorSpy).not.toHaveBeenCalled() + logErrorSpy.mockRestore() + }) + + it('33. unknown plugin name logs error and is excluded from Set', () => { + const settings = createSettings({ additive_plugins: ['labels', 'nope-plugin'] }) + const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {}) + const result = settings.normalizeAdditivePlugins() + expect(result.has('labels')).toBe(true) + expect(result.has('nope-plugin')).toBe(false) + expect(logErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/unknown or non-Diffable plugin 'nope-plugin'/)) + logErrorSpy.mockRestore() + }) + + it('34. non-Diffable plugin name (branches) logs error and is excluded', () => { + const settings = createSettings({ additive_plugins: ['branches'] }) + const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {}) + const result = settings.normalizeAdditivePlugins() + expect(result.has('branches')).toBe(false) + expect(logErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/unknown or non-Diffable plugin 'branches'/)) + logErrorSpy.mockRestore() + }) + + it('35. non-string entries log error and are skipped', () => { + const settings = createSettings({ additive_plugins: ['labels', 42, null] }) + const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {}) + const result = settings.normalizeAdditivePlugins() + expect(result).toEqual(new Set(['labels'])) + expect(logErrorSpy).toHaveBeenCalledTimes(2) // 42 + null + logErrorSpy.mockRestore() + }) + + it('36. non-array value logs error and returns empty Set', () => { + const settings = createSettings({ additive_plugins: 'labels' }) + const logErrorSpy = jest.spyOn(settings, 'logError').mockImplementation(() => {}) + const result = settings.normalizeAdditivePlugins() + expect(result.size).toBe(0) + expect(logErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/must be an array/)) + logErrorSpy.mockRestore() + }) + }) + + // ── childPluginsList returns triplets ──────────────────────────────── + describe('childPluginsList triplets', () => { + it('37. each entry includes section name as 3rd element', () => { + const settings = createSettings({ labels: [{ name: 'bug', color: 'red' }] }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const list = settings.childPluginsList({ repo: 'foo' }) + expect(list.length).toBeGreaterThan(0) + list.forEach(entry => { + expect(entry.length).toBe(3) + expect(typeof entry[2]).toBe('string') + expect(entry[2]).toMatch(/^[a-z_]+$/) + }) + }) + + it('38. section names map to the correct Settings.PLUGINS keys', () => { + const settings = createSettings({ + labels: [{ name: 'bug', color: 'red' }], + teams: [{ name: 'core', permission: 'push' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + const list = settings.childPluginsList({ repo: 'foo' }) + list.forEach(([Plugin, , section]) => { + expect(Settings.PLUGINS[section]).toBe(Plugin) + }) + }) + }) + + // ── updateRepos integration: additive flag threading ───────────────── + describe('updateRepos integration: additive flag', () => { + it('processes changed repo configs that were not returned by the installation repository list', async () => { + const settings = createSettings({ restrictedRepos: {} }) + const updateReposSpy = jest.spyOn(settings, 'updateRepos').mockResolvedValue([]) + + settings.processedRepoNames = new Set(['existing-repo']) + + await settings.updateChangedRepoConfigs([ + { owner: 'test', repo: 'existing-repo' }, + { owner: 'test', repo: 'new-repo' }, + { owner: 'test', repo: 'new-repo' } + ]) + + expect(updateReposSpy).toHaveBeenCalledTimes(1) + expect(updateReposSpy).toHaveBeenCalledWith({ owner: 'test', repo: 'new-repo' }) + }) + + it('39. plugin listed in additive_plugins has additive=true set before sync()', async () => { + const instances = [] + const syncMock = jest.fn().mockResolvedValue([]) + const LabelsCtor = jest.fn().mockImplementation(function (...args) { + this.sync = syncMock + this.hasChanges = false + instances.push(this) + }) + const savedLabels = Settings.PLUGINS.labels + Settings.PLUGINS.labels = LabelsCtor + + const repoSync = jest.fn().mockResolvedValue([]) + Settings.PLUGINS.repository = jest.fn().mockImplementation(() => ({ + sync: repoSync, renamed: false, created: false + })) + + try { + const settings = createSettings({ + additive_plugins: ['labels'], + labels: [{ name: 'bug', color: 'red' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + // Clear subOrgConfigMap so the "suborg-change early return" in + // updateRepos does not fire (mockSubOrg='frontend' sets it in ctor). + settings.subOrgConfigMap = null + // Mock childPluginsList to return just the labels triplet so we can + // control what updateRepos sees without mocking all other plugins. + jest.spyOn(settings, 'childPluginsList').mockReturnValue([ + [LabelsCtor, [{ name: 'bug', color: 'red' }], 'labels'] + ]) + jest.spyOn(settings, 'maybeReevaluateSuborg').mockResolvedValue(undefined) + await settings.updateRepos({ owner: 'o', repo: 'r' }) + expect(instances.length).toBeGreaterThan(0) + // Every labels instance must have additive=true + instances.forEach(inst => expect(inst.additive).toBe(true)) + } finally { + Settings.PLUGINS.labels = savedLabels + } + }) + + it('40. plugin NOT in additive_plugins has additive=false (default)', async () => { + const instances = [] + const syncMock = jest.fn().mockResolvedValue([]) + const TeamsCtor = jest.fn().mockImplementation(function (...args) { + this.sync = syncMock + this.hasChanges = false + instances.push(this) + }) + const savedTeams = Settings.PLUGINS.teams + Settings.PLUGINS.teams = TeamsCtor + + Settings.PLUGINS.repository = jest.fn().mockImplementation(() => ({ + sync: jest.fn().mockResolvedValue([]), renamed: false, created: false + })) + + try { + const settings = createSettings({ + additive_plugins: ['labels'], // teams is NOT listed + teams: [{ name: 'core', permission: 'push' }] + }) + settings.subOrgConfigs = {} + settings.repoConfigs = {} + // Clear subOrgConfigMap so the "suborg-change early return" does not fire. + settings.subOrgConfigMap = null + jest.spyOn(settings, 'childPluginsList').mockReturnValue([ + [TeamsCtor, [{ name: 'core', permission: 'push' }], 'teams'] + ]) + jest.spyOn(settings, 'maybeReevaluateSuborg').mockResolvedValue(undefined) + await settings.updateRepos({ owner: 'o', repo: 'r' }) + expect(instances.length).toBeGreaterThan(0) + instances.forEach(inst => expect(inst.additive).toBe(false)) + } finally { + Settings.PLUGINS.teams = savedTeams + } + }) + }) + + // ── Diffable.sync() additive behaviour ─────────────────────────────── + describe('Diffable.sync() additive behaviour', () => { + const Diffable = require('../../../lib/plugins/diffable') + + // Minimal concrete Diffable subclass for testing. + class TestDiffable extends Diffable { + constructor (nop, entries) { + super(nop, {}, { owner: 'o', repo: 'r' }, entries, { debug: jest.fn(), info: jest.fn(), error: jest.fn() }, []) + } + + find () { return Promise.resolve(this._existing || []) } + comparator (a, b) { return a.name === b.name } + changed (a, b) { return a.value !== b.value } + add (attrs) { return Promise.resolve([]) } + update (existing, attrs) { return Promise.resolve([]) } + remove (existing) { return Promise.resolve([]) } + } + + it('41. additive=false → remove() is called for unmatched existing entries', async () => { + const plugin = new TestDiffable(false, [{ name: 'keep', value: '1' }]) + plugin._existing = [ + { name: 'keep', value: '1' }, + { name: 'gone', value: '2' } // this one has no match in entries + ] + plugin.additive = false + const removeSpy = jest.spyOn(plugin, 'remove').mockResolvedValue([]) + await plugin.sync() + expect(removeSpy).toHaveBeenCalledTimes(1) + expect(removeSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'gone' })) + removeSpy.mockRestore() + }) + + it('42. additive=true → remove() is NOT called even when existing entries have no YAML match', async () => { + const plugin = new TestDiffable(false, [{ name: 'keep', value: '1' }]) + plugin._existing = [ + { name: 'keep', value: '1' }, + { name: 'gone', value: '2' } + ] + plugin.additive = true + const removeSpy = jest.spyOn(plugin, 'remove').mockResolvedValue([]) + await plugin.sync() + expect(removeSpy).not.toHaveBeenCalled() + removeSpy.mockRestore() + }) + + it('43. additive=true → add() is still called for new YAML entries', async () => { + const plugin = new TestDiffable(false, [ + { name: 'existing', value: '1' }, + { name: 'new-entry', value: '2' } + ]) + plugin._existing = [{ name: 'existing', value: '1' }] + plugin.additive = true + const addSpy = jest.spyOn(plugin, 'add').mockResolvedValue([]) + const removeSpy = jest.spyOn(plugin, 'remove').mockResolvedValue([]) + await plugin.sync() + expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'new-entry' })) + expect(removeSpy).not.toHaveBeenCalled() + addSpy.mockRestore() + removeSpy.mockRestore() + }) + + it('44. additive=true → update() is still called for changed entries', async () => { + const plugin = new TestDiffable(false, [{ name: 'item', value: 'new' }]) + plugin._existing = [{ name: 'item', value: 'old' }] + plugin.additive = true + const updateSpy = jest.spyOn(plugin, 'update').mockResolvedValue([]) + const removeSpy = jest.spyOn(plugin, 'remove').mockResolvedValue([]) + await plugin.sync() + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'item', value: 'old' }), + expect.objectContaining({ name: 'item', value: 'new' }) + ) + expect(removeSpy).not.toHaveBeenCalled() + updateSpy.mockRestore() + removeSpy.mockRestore() + }) + + it('45. NOP mode + additive=true + deletions present → INFO NopCommand about suppressed deletions', async () => { + const plugin = new TestDiffable(true, [{ name: 'keep', value: '1' }]) + plugin._existing = [ + { name: 'keep', value: '1' }, + { name: 'gone', value: '2' } + ] + plugin.additive = true + const result = await plugin.sync() + const suppressed = result.flat().filter(cmd => + cmd && cmd.type === 'INFO' && /suppressed by additive_plugins/i.test(cmd.action.msg) + ) + expect(suppressed.length).toBeGreaterThan(0) + expect(suppressed[0].action.msg).toMatch(/1 deletion/) + }) + + it('46. NOP mode + additive=true + NO deletions → no suppressed message emitted', async () => { + const plugin = new TestDiffable(true, [{ name: 'item', value: '1' }]) + plugin._existing = [{ name: 'item', value: '1' }] // identical → no changes at all + plugin.additive = true + const result = await plugin.sync() + if (result) { + const suppressed = result.flat().filter(cmd => + cmd && cmd.action && /suppressed by additive_plugins/i.test(cmd.action.msg) + ) + expect(suppressed.length).toBe(0) + } + // result may be undefined (no changes) which is also correct + }) + }) + }) + + describe('getReposRemovedFromSubOrgTargeting', () => { + let settings + + beforeEach(() => { + stubConfig = { restrictedRepos: {} } + settings = createSettings(stubConfig) + }) + + it('returns empty array when no changedSubOrgs provided', async () => { + const result = await settings.getReposRemovedFromSubOrgTargeting([], 'prev-sha') + expect(result).toEqual([]) + }) + + it('returns empty array when no baseRef provided', async () => { + const result = await settings.getReposRemovedFromSubOrgTargeting([{ path: '.github/suborgs/frontend.yml' }], null) + expect(result).toEqual([]) + }) + + it('identifies repos removed from suborgrepos targeting', async () => { + // Previous config had repo-a and repo-b in suborgrepos + const previousContent = Buffer.from(yaml.dump({ + suborgrepos: ['repo-a', 'repo-b'], + teams: [{ name: 'core', permission: 'push' }] + })).toString('base64') + + stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => { + if (params.ref === 'prev-sha') { + return Promise.resolve({ data: { content: previousContent } }) + } + // Current config: default mock (has new-repo in suborgrepos) + const currentContent = Buffer.from(yaml.dump({ + suborgrepos: ['repo-b'], + teams: [{ name: 'core', permission: 'push' }] + })).toString('base64') + return Promise.resolve({ data: { content: currentContent } }) + }) + + // Current subOrgConfigs only has repo-b (repo-a was removed from targeting) + settings.subOrgConfigs = { + 'repo-b': { source: '.github/suborgs/frontend.yml' } + } + + const result = await settings.getReposRemovedFromSubOrgTargeting( + [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }], + 'prev-sha' + ) + + expect(result).toContain('repo-a') + expect(result).not.toContain('repo-b') + }) + + it('identifies repos removed from suborgteams targeting', async () => { + // Previous config used suborgteams: [team-a] + const previousContent = Buffer.from(yaml.dump({ + suborgteams: ['team-a'], + teams: [{ name: 'core', permission: 'push' }] + })).toString('base64') + + stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => { + if (params.ref === 'prev-sha') { + return Promise.resolve({ data: { content: previousContent } }) + } + return Promise.resolve({ data: { content: previousContent } }) + }) + + // Mock getReposForTeam to return repos for team-a + settings.getReposForTeam = jest.fn().mockResolvedValue([ + { name: 'team-repo-1' }, + { name: 'team-repo-2' } + ]) + + // Current subOrgConfigs: only team-repo-1 still matches (team-repo-2 was removed) + settings.subOrgConfigs = { + 'team-repo-1': { source: '.github/suborgs/frontend.yml' } + } + + const result = await settings.getReposRemovedFromSubOrgTargeting( + [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }], + 'prev-sha' + ) + + expect(result).toContain('team-repo-2') + expect(result).not.toContain('team-repo-1') + }) + + it('identifies repos removed from suborgproperties targeting', async () => { + // Previous config used suborgproperties + const previousContent = Buffer.from(yaml.dump({ + suborgproperties: [{ EDP: true }], + teams: [{ name: 'core', permission: 'push' }] + })).toString('base64') + + stubContext.octokit.repos.getContent = jest.fn().mockImplementation((params) => { + if (params.ref === 'prev-sha') { + return Promise.resolve({ data: { content: previousContent } }) + } + return Promise.resolve({ data: { content: previousContent } }) + }) + + // Mock getSubOrgRepositories to return repos with the property + settings.getSubOrgRepositories = jest.fn().mockResolvedValue([ + { repository_name: 'prop-repo-1' }, + { repository_name: 'prop-repo-2' } + ]) + + // Current subOrgConfigs: only prop-repo-1 still matches + settings.subOrgConfigs = { + 'prop-repo-1': { source: '.github/suborgs/frontend.yml' } + } + + const result = await settings.getReposRemovedFromSubOrgTargeting( + [{ path: '.github/suborgs/frontend.yml', name: 'frontend' }], + 'prev-sha' + ) + + expect(result).toContain('prop-repo-2') + expect(result).not.toContain('prop-repo-1') + }) + + it('deduplicates removed repos across multiple suborg files', async () => { + const previousContent = Buffer.from(yaml.dump({ + suborgrepos: ['repo-a', 'repo-b'] + })).toString('base64') + + stubContext.octokit.repos.getContent = jest.fn().mockResolvedValue({ + data: { content: previousContent } + }) + + // Neither repo matches current targeting + settings.subOrgConfigs = {} + + const result = await settings.getReposRemovedFromSubOrgTargeting( + [ + { path: '.github/suborgs/frontend.yml', name: 'frontend' }, + { path: '.github/suborgs/frontend.yml', name: 'frontend' } // duplicate + ], + 'prev-sha' + ) + + // Should be deduplicated + const repoACount = result.filter(r => r === 'repo-a').length + expect(repoACount).toBe(1) + }) + + it('handles 404 gracefully when previous file does not exist', async () => { + stubContext.octokit.repos.getContent = jest.fn().mockRejectedValue( + Object.assign(new Error('Not Found'), { status: 404 }) + ) + + settings.subOrgConfigs = {} + + const result = await settings.getReposRemovedFromSubOrgTargeting( + [{ path: '.github/suborgs/new-suborg.yml', name: 'new-suborg' }], + 'prev-sha' + ) + + expect(result).toEqual([]) + }) + }) }) // Settings Tests diff --git a/test/unit/lib/settingsGenerator.test.js b/test/unit/lib/settingsGenerator.test.js new file mode 100644 index 000000000..6feef5c34 --- /dev/null +++ b/test/unit/lib/settingsGenerator.test.js @@ -0,0 +1,263 @@ +/* eslint-disable no-undef */ +const SettingsGenerator = require('../../../lib/settingsGenerator') +const { + intersectConfigs, + deepEqual, + pruneEmpty, + stripNoise, + parsePropertyValue, + toYaml +} = SettingsGenerator + +const silentLog = { debug () {}, info () {}, warn () {}, error () {}, trace () {}, child () { return this } } + +function makeGenerator (github = {}) { + return new SettingsGenerator(github, 'my-org', { log: silentLog }) +} + +describe('SettingsGenerator helpers', () => { + describe('deepEqual', () => { + it('compares scalars, arrays and objects', () => { + expect(deepEqual(1, 1)).toBe(true) + expect(deepEqual('a', 'b')).toBe(false) + expect(deepEqual([1, 2], [1, 2])).toBe(true) + expect(deepEqual([1, 2], [2, 1])).toBe(false) + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true) + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + }) + + describe('stripNoise', () => { + it('removes API-only keys recursively and drops nulls', () => { + const input = { + id: 5, + node_id: 'abc', + name: 'keep', + created_at: 'x', + nested: { url: 'u', value: 1, gone: null }, + list: [{ id: 1, ok: true }] + } + expect(stripNoise(input)).toEqual({ + name: 'keep', + nested: { value: 1 }, + list: [{ ok: true }] + }) + }) + }) + + describe('pruneEmpty', () => { + it('removes empty arrays, empty objects, null and undefined', () => { + expect(pruneEmpty({ + a: [], + b: {}, + c: null, + d: undefined, + e: [1], + f: { x: 1 }, + g: 'value' + })).toEqual({ e: [1], f: { x: 1 }, g: 'value' }) + }) + }) + + describe('parsePropertyValue', () => { + it('parses name=value', () => { + expect(parsePropertyValue('Team=backend')).toEqual({ name: 'Team', value: 'backend' }) + }) + it('parses name:value', () => { + expect(parsePropertyValue('Team:backend')).toEqual({ name: 'Team', value: 'backend' }) + }) + it('uses propertyName when provided', () => { + expect(parsePropertyValue('backend', 'Team')).toEqual({ name: 'Team', value: 'backend' }) + }) + it('throws when value cannot be parsed', () => { + expect(() => parsePropertyValue('backend')).toThrow(/name=value/) + }) + }) + + describe('toYaml', () => { + it('serializes config to YAML', () => { + const out = toYaml({ repository: { name: 'test' } }) + expect(out).toContain('repository:') + expect(out).toContain('name: test') + }) + }) +}) + +describe('intersectConfigs', () => { + it('returns the single config unchanged when only one provided', () => { + const cfg = { repository: { has_issues: true } } + expect(intersectConfigs([cfg])).toBe(cfg) + }) + + it('keeps only sections present in all configs', () => { + const a = { repository: { has_issues: true }, labels: [{ name: 'bug' }] } + const b = { repository: { has_issues: true } } + expect(intersectConfigs([a, b])).toEqual({ repository: { has_issues: true } }) + }) + + it('keeps only scalar object keys that match across all configs', () => { + const a = { repository: { has_issues: true, has_wiki: true } } + const b = { repository: { has_issues: true, has_wiki: false } } + expect(intersectConfigs([a, b])).toEqual({ repository: { has_issues: true } }) + }) + + it('keeps array items present (by identity + value) in every config', () => { + const a = { labels: [{ name: 'bug', color: 'f00' }, { name: 'wip', color: '0f0' }] } + const b = { labels: [{ name: 'bug', color: 'f00' }, { name: 'done', color: '00f' }] } + expect(intersectConfigs([a, b])).toEqual({ labels: [{ name: 'bug', color: 'f00' }] }) + }) + + it('drops array items whose value differs even if identity matches', () => { + const a = { labels: [{ name: 'bug', color: 'f00' }] } + const b = { labels: [{ name: 'bug', color: '00f' }] } + expect(intersectConfigs([a, b])).toEqual({ labels: [] }) + }) +}) + +describe('SettingsGenerator extractors', () => { + it('repository() selects only configurable fields', async () => { + const github = { + repos: { + get: jest.fn().mockResolvedValue({ + data: { + id: 1, + node_id: 'x', + name: 'test', + description: 'desc', + has_issues: true, + stargazers_count: 99, + topics: ['a', 'b'], + default_branch: 'main' + } + }) + } + } + const generator = makeGenerator(github) + const result = await generator.repository({ owner: 'my-org', repo: 'test' }) + expect(result).toEqual({ + name: 'test', + description: 'desc', + has_issues: true, + default_branch: 'main', + topics: ['a', 'b'] + }) + }) + + it('labels() sanitizes to name/color/description', async () => { + const generator = makeGenerator() + generator.findExisting = jest.fn().mockResolvedValue([ + { id: 1, node_id: 'n', url: 'u', name: 'bug', color: 'cc0000', description: 'A bug', default: false } + ]) + expect(await generator.labels({ owner: 'my-org', repo: 'r' })).toEqual([ + { name: 'bug', color: 'cc0000', description: 'A bug' } + ]) + }) + + it('teams() maps slug and permission', async () => { + const generator = makeGenerator() + generator.findExisting = jest.fn().mockResolvedValue([ + { id: 1, slug: 'core', name: 'Core Team', permission: 'push' } + ]) + expect(await generator.teams({ owner: 'my-org', repo: 'r' })).toEqual([ + { name: 'core', permission: 'push' } + ]) + }) + + it('rulesets() strips source/source_type and noise', async () => { + const generator = makeGenerator() + generator.findExisting = jest.fn().mockResolvedValue([ + { id: 7, node_id: 'n', source: 'my-org/r', source_type: 'Repository', name: 'main', enforcement: 'active' } + ]) + expect(await generator.rulesets({ owner: 'my-org', repo: 'r' }, 'repo')).toEqual([ + { name: 'main', enforcement: 'active' } + ]) + }) + + it('reformatBranchProtection flattens enabled wrappers', () => { + const generator = makeGenerator() + const out = generator.reformatBranchProtection({ + url: 'noise', + enforce_admins: { enabled: true }, + required_linear_history: { enabled: false }, + required_pull_request_reviews: { required_approving_review_count: 2 } + }) + expect(out).toEqual({ + enforce_admins: true, + required_linear_history: false, + required_pull_request_reviews: { required_approving_review_count: 2 } + }) + }) +}) + +describe('SettingsGenerator.buildSubOrgConfig', () => { + it('prepends suborgproperties and intersects matching repos', async () => { + const generator = makeGenerator() + generator.findReposByProperty = jest.fn().mockResolvedValue(['repo-a', 'repo-b']) + generator.buildRepoConfig = jest.fn() + .mockResolvedValueOnce({ repository: { has_issues: true, has_wiki: true } }) + .mockResolvedValueOnce({ repository: { has_issues: true, has_wiki: false } }) + + const result = await generator.buildSubOrgConfig('Team', 'backend') + expect(result).toEqual({ + suborgproperties: [{ Team: 'backend' }], + repository: { has_issues: true } + }) + }) + + it('returns just the selector when no repos match', async () => { + const generator = makeGenerator() + generator.findReposByProperty = jest.fn().mockResolvedValue([]) + const result = await generator.buildSubOrgConfig('Team', 'backend') + expect(result).toEqual({ suborgproperties: [{ Team: 'backend' }] }) + }) +}) + +describe('SettingsGenerator.generate', () => { + it('resolves repo source to repos/.yml', async () => { + const generator = makeGenerator() + generator.buildRepoConfig = jest.fn().mockResolvedValue({ repository: { name: 'r' } }) + const { filePath, config, yaml } = await generator.generate({ sourceType: 'repo', sourceValue: 'r' }) + expect(filePath).toBe('.github/repos/r.yml') + expect(config).toEqual({ repository: { name: 'r' } }) + expect(yaml).toContain('name: r') + }) + + it('resolves org source to settings.yml', async () => { + const generator = makeGenerator() + generator.buildOrgConfig = jest.fn().mockResolvedValue({ rulesets: [] }) + const { filePath } = await generator.generate({ sourceType: 'org', sourceValue: 'my-org' }) + expect(filePath).toBe('.github/settings.yml') + }) + + it('resolves custom-property source to suborgs/_.yml', async () => { + const generator = makeGenerator() + generator.buildSubOrgConfig = jest.fn().mockResolvedValue({ suborgproperties: [{ Team: 'backend' }] }) + const { filePath } = await generator.generate({ sourceType: 'custom-property', sourceValue: 'Team=backend' }) + expect(filePath).toBe('.github/suborgs/Team_backend.yml') + }) + + it('throws on unsupported source type', async () => { + const generator = makeGenerator() + await expect(generator.generate({ sourceType: 'bogus', sourceValue: 'x' })).rejects.toThrow(/Unsupported source type/) + }) +}) + +describe('SettingsGenerator.findReposByProperty', () => { + it('queries the org properties values API and returns repo names', async () => { + const paginate = jest.fn().mockResolvedValue([ + { repository_name: 'repo-a' }, + { repository_name: 'repo-b' }, + { repository_name: null } + ]) + const github = { + request: { endpoint: jest.fn().mockReturnValue({ url: 'endpoint' }) }, + paginate + } + const generator = makeGenerator(github) + const repos = await generator.findReposByProperty('Team', 'backend') + expect(github.request.endpoint).toHaveBeenCalledWith( + expect.stringContaining('/orgs/my-org/properties/values?repository_query=') + ) + expect(repos).toEqual(['repo-a', 'repo-b']) + }) +}) diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 000000000..97a2bb84e --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next", "next/core-web-vitals"] +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..26b002aac --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..e8de83917 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,69 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/route.ts`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## API Routes + +This directory contains example API routes for the headless API app. + +For more details, see [route.js file convention](https://nextjs.org/docs/app/api-reference/file-conventions/route). + +--- + +```mermaid + +sequenceDiagram + participant User + participant OrganizationsTable.jsx + participant HubOrgGraph.jsx + participant Next.js API Proxy + participant Backend (Express) + participant GitHub API + + User->>OrganizationsTable.jsx: Loads Organization page + OrganizationsTable.jsx->>Next.js API Proxy: GET /api/safe-settings/installation + Next.js API Proxy->>Backend (Express): GET /api/safe-settings/installation + Backend (Express)->>GitHub API: Fetch org installations, repo status, commit info, sync status + GitHub API-->>Backend (Express): Returns org data + Backend (Express)-->>Next.js API Proxy: Returns installations array + Next.js API Proxy-->>OrganizationsTable.jsx: Returns installations array + OrganizationsTable.jsx->>HubOrgGraph.jsx: Passes org data (hasConfigRepo, isInSync) + HubOrgGraph.jsx->>Next.js API Proxy: (if fetching own data) GET /api/safe-settings/installation + Next.js API Proxy->>Backend (Express): GET /api/safe-settings/installation + Backend (Express)->>GitHub API: (repeat fetch if needed) + GitHub API-->>Backend (Express): Returns org data + Backend (Express)-->>Next.js API Proxy: Returns installations array + Next.js API Proxy-->>HubOrgGraph.jsx: Returns installations array + User->>OrganizationsTable.jsx: Interacts with table/graph (tooltips, legend, etc.) +``` \ No newline at end of file diff --git a/ui/favicon.svg b/ui/favicon.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/next.config.js b/ui/next.config.js new file mode 100644 index 000000000..bcf5fa482 --- /dev/null +++ b/ui/next.config.js @@ -0,0 +1,28 @@ + +// Normalize URL prefix: add leading '/' if missing, treat '/' as empty for root +const normalizePrefix = (prefix) => { + if (!prefix || prefix === '/') return ''; + return prefix.startsWith('/') ? prefix : `/${prefix}`; +}; + +const basePath = normalizePrefix(process.env.SAFE_SETTINGS_HUB_URL_PREFIX || '/safe-settings'); + +const nextConfig = { + output: "export", + basePath: basePath, + // Disable Next.js ESLint checks during builds + eslint: { + ignoreDuringBuilds: true, + }, + async redirects() { + return [ + { + source: '/', + destination: '/dashboard', + permanent: false, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 000000000..23c0972c1 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,7757 @@ +{ + "name": "ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.1.0", + "dependencies": { + "@primer/octicons-react": "^19.15.5", + "bootstrap": "^5.3.7", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "mermaid": "^11.14.0", + "next": "^15.4.7", + "react-markdown": "^10.1.0", + "rehype-mermaid": "^3.0.0", + "swr": "^2.3.6" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.4.7", + "tailwindcss": "^4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "12.0.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "license": "Apache-2.0" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.2.tgz", + "integrity": "sha512-jVf75icVVgSVGf9+QWBeCHdFL35yZ06HMHl9sCa059pITTP781lOacvRazfwAmXDKiBiUdQQMWVnuiw/RaQNhQ==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, + "node_modules/@next/env": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.7.tgz", + "integrity": "sha512-PrBIpO8oljZGTOe9HH0miix1w5MUiGJ/q83Jge03mHEE0E3pyqzAy2+l5G6aJDbXoobmxPJTVhbCuwlLtjSHwg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.4.7.tgz", + "integrity": "sha512-asj3RRiEruRLVr+k2ZC4hll9/XBzegMpFMr8IIRpNUYypG86m/a76339X2WETl1C53A512w2INOc2KZV769KPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.7.tgz", + "integrity": "sha512-2Dkb+VUTp9kHHkSqtws4fDl2Oxms29HcZBwFIda1X7Ztudzy7M6XF9HDS2dq85TmdN47VpuhjE+i6wgnIboVzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.7.tgz", + "integrity": "sha512-qaMnEozKdWezlmh1OGDVFueFv2z9lWTcLvt7e39QA3YOvZHNpN2rLs/IQLwZaUiw2jSvxW07LxMCWtOqsWFNQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.7.tgz", + "integrity": "sha512-ny7lODPE7a15Qms8LZiN9wjNWIeI+iAZOFDOnv2pcHStncUr7cr9lD5XF81mdhrBXLUP9yT9RzlmSWKIazWoDw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.7.tgz", + "integrity": "sha512-4SaCjlFR/2hGJqZLLWycccy1t+wBrE/vyJWnYaZJhUVHccpGLG5q0C+Xkw4iRzUIkE+/dr90MJRUym3s1+vO8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.7.tgz", + "integrity": "sha512-2uNXjxvONyRidg00VwvlTYDwC9EgCGNzPAPYbttIATZRxmOZ3hllk/YYESzHZb65eyZfBR5g9xgCZjRAl9YYGg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.7.tgz", + "integrity": "sha512-ceNbPjsFgLscYNGKSu4I6LYaadq2B8tcK116nVuInpHHdAWLWSwVK6CHNvCi0wVS9+TTArIFKJGsEyVD1H+4Kg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.7.tgz", + "integrity": "sha512-pZyxmY1iHlZJ04LUL7Css8bNvsYAMYOY9JRwFA3HZgpaNKsJSowD09Vg2R9734GxAcLJc2KDQHSCR91uD6/AAw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.7.tgz", + "integrity": "sha512-HjuwPJ7BeRzgl3KrjKqD2iDng0eQIpIReyhpF5r4yeAHFwWRuAhfW92rWv/r3qeQHEwHsLRzFDvMqRjyM5DI6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@primer/octicons-react": { + "version": "19.15.5", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.15.5.tgz", + "integrity": "sha512-JEoxBVkd6F8MaKEO1QKau0Nnk3IVroYn7uXGgMqZawcLQmLljfzua3S1fs2FQs295SYM9I6DlkESgz5ORq5yHA==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz", + "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "postcss": "^8.4.41", + "tailwindcss": "4.1.12" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.40.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", + "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "chevrotain": "^12.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "devOptional": 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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "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/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/cytoscape": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", + "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.4.7.tgz", + "integrity": "sha512-tkKKNVJKI4zMIgTpvG2x6mmdhuOdgXUL3AaSPHwxLQkvzi4Yryqvk6B0R5Z4gkpe7FKopz3ZmlpePH3NTHy3gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.4.7", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "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/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "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.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/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/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "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/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", + "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", + "license": "MIT", + "dependencies": { + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.3", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/mermaid": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid-isomorphic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mermaid-isomorphic/-/mermaid-isomorphic-3.1.0.tgz", + "integrity": "sha512-mzrvfEVjnJIkJlEqxp3eMuR1wS0TeLCH1VK5E/T5yzWaBwI3JqjJuw70yUIThSCDJ5bRs6O3rgfp00oBAbvSeQ==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-free": "^6.0.0", + "katex": "^0.16.0", + "mermaid": "^11.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "playwright": "1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "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/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.4.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.7.tgz", + "integrity": "sha512-OcqRugwF7n7mC8OSYjvsZhhG1AYSvulor1EIUsIkbbEbf1qoE5EbH36Swj8WhF4cHqmDgkiam3z1c1W0J1Wifg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.4.7", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.4.7", + "@next/swc-darwin-x64": "15.4.7", + "@next/swc-linux-arm64-gnu": "15.4.7", + "@next/swc-linux-arm64-musl": "15.4.7", + "@next/swc-linux-x64-gnu": "15.4.7", + "@next/swc-linux-x64-musl": "15.4.7", + "@next/swc-win32-arm64-msvc": "15.4.7", + "@next/swc-win32-x64-msvc": "15.4.7", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "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-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-mermaid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-mermaid/-/rehype-mermaid-3.0.0.tgz", + "integrity": "sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "mermaid-isomorphic": "^3.0.0", + "mini-svg-data-uri": "^1.0.0", + "space-separated-tokens": "^2.0.0", + "unified": "^11.0.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "playwright": "1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "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/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "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/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" + } + }, + "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/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.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==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..83f3571e5 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,30 @@ +{ + "name": "ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack --port 3001", + "build": "next build", + "export": "next export", + "start": "next start" + }, + "dependencies": { + "@primer/octicons-react": "^19.15.5", + "bootstrap": "^5.3.7", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "mermaid": "^11.14.0", + "next": "^15.4.7", + "react-markdown": "^10.1.0", + "rehype-mermaid": "^3.0.0", + "swr": "^2.3.6" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.4.7", + "tailwindcss": "^4" + } +} diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 000000000..e69de29bb diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/shield.png b/ui/shield.png new file mode 100644 index 000000000..93d8476ff Binary files /dev/null and b/ui/shield.png differ diff --git a/ui/shield.svg b/ui/shield.svg new file mode 100644 index 000000000..17791cf9b --- /dev/null +++ b/ui/shield.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/app/[slug]/route.js b/ui/src/app/[slug]/route.js new file mode 100644 index 000000000..290f31842 --- /dev/null +++ b/ui/src/app/[slug]/route.js @@ -0,0 +1,18 @@ +const { NextResponse } = require('next/server'); + +// JS version: no type annotations +export async function GET(request, context) { + const params = context && context.params ? context.params : {}; + const slug = params.slug || ''; + return NextResponse.json({ message: `Hello ${slug}!` }); +} + +export async function generateStaticParams() { + // Replace with your actual slugs + return [ + { slug: 'example1' }, + { slug: 'example2' } + ]; +} + +export const dynamic = 'force-static'; \ No newline at end of file diff --git a/ui/src/app/api/logs/route.js b/ui/src/app/api/logs/route.js new file mode 100644 index 000000000..ec873364a --- /dev/null +++ b/ui/src/app/api/logs/route.js @@ -0,0 +1,28 @@ +import fs from 'fs/promises' +import path from 'path' + +export const dynamic = 'force-static' + +async function findLogFile () { + const candidates = [] + if (process.env.SAFE_SETTINGS_LOG_FILE) candidates.push(process.env.SAFE_SETTINGS_LOG_FILE) + candidates.push(path.join(process.cwd(), 'safe-settings.log')) + candidates.push(path.join(process.cwd(), '..', 'safe-settings.log')) + candidates.push(path.join(process.cwd(), '..', '..', 'safe-settings.log')) + + for (const p of candidates) { + if (!p) continue + try { + const st = await fs.stat(p) + if (st && st.isFile()) return p + } catch (e) { + // ignore + } + } + return null +} + +export async function GET () { + const msg = 'Disabled in static export: use the backend endpoint /api/safe-settings/logs or set SAFE_SETTINGS_LOG_FILE to point at the log file.' + return new Response(msg, { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' } }) +} diff --git a/ui/src/app/components/EnvVariables.jsx b/ui/src/app/components/EnvVariables.jsx new file mode 100644 index 000000000..c68a0405d --- /dev/null +++ b/ui/src/app/components/EnvVariables.jsx @@ -0,0 +1,151 @@ +'use client'; +import { ChevronDownIcon, ChevronUpIcon, CopyIcon, SearchIcon, ShieldIcon } from '@primer/octicons-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useHydrated } from '../hooks/useHydrated'; +import { withBasePath } from '../utils/basePath'; + +const SENSITIVE_REGEX = /(secret|token|key|password|private)/i; + +export default function EnvVariables() { + const hydrated = useHydrated(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [rows, setRows] = useState([]); + const [search, setSearch] = useState(''); + const [includeInfra, setIncludeInfra] = useState(false); + const [revealAll, setRevealAll] = useState(false); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); + + const fetchData = () => { + if (!hydrated) return; + setLoading(true); setError(null); + fetch(withBasePath(`/api/safe-settings/app/env${includeInfra ? '?includeInfra=true' : ''}`)) + .then(r => { + if (!r.ok) { + throw new Error(`Unable to retrieve environment variables (HTTP ${r.status}). Please try again later.`); + } + return r.json(); + }) + .then(json => { + setRows(json.variables || []); + setLastFetchedAt(new Date(json.updatedAt || Date.now())); + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchData(); /* eslint-disable-next-line */ }, [hydrated, includeInfra]); + + const filtered = useMemo(() => { + if (!search) return rows; + const q = search.toLowerCase(); + return rows.filter(r => + r.key.toLowerCase().includes(q) || + (r.value + '').toLowerCase().includes(q) || + (r.description || '').toLowerCase().includes(q) + ); + }, [rows, search]); + + const sorted = useMemo(() => { + if (!sortConfig.key || !sortConfig.direction) return filtered; + const list = [...filtered]; + list.sort((a, b) => { + let av = a[sortConfig.key]; + let bv = b[sortConfig.key]; + if (av == null) av = ''; + if (bv == null) bv = ''; + av = (av + '').toLowerCase(); + bv = (bv + '').toLowerCase(); + if (av < bv) return sortConfig.direction === 'asc' ? -1 : 1; + if (av > bv) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + return list; + }, [filtered, sortConfig]); + + const cycleSort = (key) => { + setSortConfig(prev => { + if (prev.key === key) { + if (prev.direction === 'asc') return { key, direction: 'desc' }; + if (prev.direction === 'desc') return { key: null, direction: null }; + } + return { key, direction: 'asc' }; + }); + }; + + const renderSortIcon = (key) => { + if (sortConfig.key !== key) return ; + if (sortConfig.direction === 'asc') return ; + if (sortConfig.direction === 'desc') return ; + return ; + }; + + const maskedValue = (k, v) => { + if (revealAll) return v; + if (!SENSITIVE_REGEX.test(k)) return v; + if (!v) return v; + if (v.length <= 4) return '*'.repeat(v.length); + return v.slice(0, 2) + '***' + v.slice(-2); + }; + + const copyToClipboard = (text) => { + try { navigator.clipboard.writeText(text); } catch(_) {} + } + + return ( +
+
+
+ +
+ + setSearch(e.target.value)} /> +
+
+ {/* Removed options and buttons section for a cleaner environment page UI */} +
+ + {loading &&
Loading…
} + {error && !loading &&
Error: {error}
} + {!loading && !error && filtered.length === 0 &&
No variables
} + + {!loading && !error && filtered.length > 0 && ( +
+
MsgPluginRepoAdditionsDeletionsModifications
❗ ${y.action.msg} ${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)}
${y.plugin} ${prettify(y.repo)} ${prettify(y.action.additions)} ${prettify(y.action.deletions)} ${prettify(y.action.modifications)}
+ + + + + + + + + + + {sorted.map(r => { + const sensitive = SENSITIVE_REGEX.test(r.key); + return ( + + + + + + + + ); + })} + +
cycleSort('key')} className="theme-text-primary user-select-none" style={{ width: '20%', cursor: 'pointer' }}>Key {renderSortIcon('key')} cycleSort('value')} className="theme-text-primary user-select-none" style={{ width: '30%', cursor: 'pointer' }}>Value {renderSortIcon('value')} cycleSort('description')} className="theme-text-primary user-select-none" style={{ cursor: 'pointer' }}>Description {renderSortIcon('description')}
{r.key} + {maskedValue(r.key, r.value)} + {r.description || 'NA'}{sensitive && } + +
+ + )} +
+ {sorted.length} shown / {rows.length} total +
+ + ); +} diff --git a/ui/src/app/components/HubOrgGraph.jsx b/ui/src/app/components/HubOrgGraph.jsx new file mode 100644 index 000000000..201259323 --- /dev/null +++ b/ui/src/app/components/HubOrgGraph.jsx @@ -0,0 +1,141 @@ +'use client'; +import { useEffect, useRef } from "react"; +import useSWR from "swr"; +import { withBasePath } from "../utils/basePath"; + +const fetcher = (...args) => fetch(...args).then(res => res.json()); + +export default function HubOrgGraph({ width = 640, height = 320 }) { + const vizRef = useRef(null); + const { data, error } = useSWR(withBasePath("/api/safe-settings/installation"), fetcher); + const orgs = Array.isArray(data?.installations) + ? data.installations.filter(i => i.type === "Organization") + : []; + const orgCount = orgs.length; + + useEffect(() => { + if (typeof window === "undefined" || !data) return; + Promise.all([ + import("d3-selection"), + import("d3-force"), + import("d3-drag") + ]).then(([d3Selection, d3Force, d3Drag]) => { + const select = d3Selection.select; + const forceSimulation = d3Force.forceSimulation; + const forceLink = d3Force.forceLink; + const forceManyBody = d3Force.forceManyBody; + const forceCenter = d3Force.forceCenter; + const drag = d3Drag.drag; + // Dynamic graph data: 1 HUB, N ORGs + const nodes = [ { id: "Hub", group: 1, label: "Hub", color: "#0a2540" } ]; + if (orgs.length > 0) { + orgs.forEach((org, i) => { + const orgKey = org.account; + const hasConfigRepo = org.hasConfigRepo === true; + nodes.push({ id: orgKey, group: 2, label: "ORG", color: hasConfigRepo ? "#2ea44f" : "#6a737d", tooltip: org.account }); + }); + } else { + for (let i = 1; i <= orgCount; i++) { + nodes.push({ id: `ORG${i}`, group: 2, label: "ORG", color: "#6a737d", tooltip: `ORG${i}` }); + } + } + const links = []; + if (orgs.length > 0) { + orgs.forEach((org, i) => { + const orgKey = org.account; + links.push({ source: "Hub", target: orgKey }); + }); + } else { + for (let i = 1; i <= orgCount; i++) { + links.push({ source: "Hub", target: `ORG${i}` }); + } + } + select(vizRef.current).selectAll("svg").remove(); + const svg = select(vizRef.current) + .append("svg") + .attr("width", width) + .attr("height", height); + const simulation = forceSimulation(nodes) + .force("link", forceLink(links).id(d => d.id).distance(120)) + .force("charge", forceManyBody().strength(-400)) + .force("center", forceCenter(width / 2, height / 2)); + const link = svg.append("g") + .attr("stroke", "#999") + .attr("stroke-opacity", 0.6) + .selectAll("line") + .data(links) + .join("line") + .attr("stroke-width", 2); + const node = svg.append("g") + .attr("stroke", "#fff") + .attr("stroke-width", 2) + .selectAll("circle") + .data(nodes) + .join("circle") + .attr("r", 24) + .attr("fill", d => d.group === 1 ? d.color : d.color || "#6f42c1") + .call(drag() + .on("start", (event, d) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; d.fy = d.y; + }) + .on("drag", (event, d) => { + d.fx = event.x; d.fy = event.y; + }) + .on("end", (event, d) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; d.fy = null; + }) + ); + node.append("title") + .text(d => d.group === 2 ? d.tooltip : "Hub"); + const label = svg.append("g") + .selectAll("text") + .data(nodes) + .join("text") + .attr("text-anchor", "middle") + .attr("dy", ".35em") + .attr("font-size", 16) + .attr("font-family", "sans-serif") + .attr("fill", d => d.group === 1 ? "#fff" : "#fff") + .text(d => d.label) + .each(function(d) { + d3Selection.select(this) + .append("title") + .text(d.group === 2 ? d.tooltip : "Hub"); + }); + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + node + .attr("cx", d => d.x) + .attr("cy", d => d.y); + label + .attr("x", d => d.x) + .attr("y", d => d.y); + }); + }); + }, [width, height, orgCount, data]); + + if (error) return
Error loading organization graph.
; + if (!data) return
Loading organization graph...
; + + return ( +
+
+
+ + + Has safe-settings admin repo + + + + No safe-settings admin repo + +
+
+ ); +} diff --git a/ui/src/app/components/OrganizationsTable.jsx b/ui/src/app/components/OrganizationsTable.jsx new file mode 100644 index 000000000..4a8f621b0 --- /dev/null +++ b/ui/src/app/components/OrganizationsTable.jsx @@ -0,0 +1,583 @@ +"use client"; + +import { + ChevronDownIcon, + ChevronUpIcon, + InfoIcon, + SearchIcon, +} from "@primer/octicons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useHydrated } from "../hooks/useHydrated"; +import { withBasePath } from "../utils/basePath"; + +// Mock organizations used when /api/safe-settings/installation returns 404 +const MOCK_ORGS = [ + { + id: 1, + name: "mock-org-one", + lastSyncDate: new Date(Date.now() - 3600 * 1000).toISOString(), + lastSyncMessage: "Initial mock sync", + lastSyncSha: "abcdef1", + ageSeconds: 3600, + }, + { + id: 2, + name: "example-inc", + lastSyncDate: new Date(Date.now() - 7200 * 1000).toISOString(), + lastSyncMessage: "Second mock sync", + lastSyncSha: "abcdef2", + ageSeconds: 7200, + }, + { + id: 3, + name: "demo-labs", + lastSyncDate: null, + lastSyncMessage: null, + lastSyncSha: null, + ageSeconds: null, + na: true, + }, +]; + +const OrganizationsTable = ({ organizations: propOrganizations = [] }) => { + const [searchTerm, setSearchTerm] = useState(""); + const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [fetched, setFetched] = useState([]); + const hydrated = useHydrated(); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const headerCheckboxRef = useRef(null); + const [retrievingFiles, setRetrievingFiles] = useState(false); + const [retrieveMessage, setRetrieveMessage] = useState(null); + const [retrieveError, setRetrieveError] = useState(null); + const [retrieveResults, setRetrieveResults] = useState(null); + + // Fetch real organizations from backend API on client hydration + useEffect(() => { + if (!hydrated) return; // avoid SSR mismatch + let cancelled = false; + setLoading(true); + + fetch(withBasePath("/api/safe-settings/installation")) + .then((r) => { + if (!r.ok) { + throw new Error( + `Unable to retrieve organizations (HTTP ${r.status}). Please try again later.` + ); + } + return r.json(); + }) + .then((json) => { + if (!json || cancelled) return; + const mapped = (json.installations || []).map((i) => ({ + id: i.id, + name: i.account, + lastSyncDate: i.committed_at || null, + lastSyncSha: i.sha || null, + lastSyncMessage: i.message || null, + ageSeconds: typeof i.age_seconds === "number" ? i.age_seconds : null, + hasConfigRepo: + typeof i.hasConfigRepo === "boolean" ? i.hasConfigRepo : false, + isInSync: typeof i.isInSync === "boolean" ? i.isInSync : false, + })); + setFetched(mapped); + setError(null); + }) + .catch((e) => { + if (!cancelled) setError(e.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [hydrated]); + + const data = + fetched.length > 0 + ? fetched + : propOrganizations.length > 0 + ? propOrganizations + : []; + + // Format date for display with hydration-safe approach + const formatLastSync = (org) => { + if (!org.lastSyncDate) return ; + const dateObj = new Date(org.lastSyncDate); + let ageSec = org.ageSeconds; + if (hydrated && ageSec == null) { + ageSec = Math.floor((Date.now() - dateObj.getTime()) / 1000); + } + const rel = (() => { + if (ageSec == null) return ""; + if (ageSec < 60) return "0m"; + const mTotal = Math.floor(ageSec / 60); + if (mTotal < 60) return `${mTotal}m`; + const hTotal = Math.floor(mTotal / 60); + if (hTotal < 24) { + const remM = mTotal % 60; + return remM ? `${hTotal}h ${remM}m` : `${hTotal}h`; + } + const dTotal = Math.floor(hTotal / 24); + const remH = hTotal % 24; + return remH ? `${dTotal}d ${remH}h` : `${dTotal}d`; + })(); + const fullStamp = `${dateObj.getFullYear()}-${String( + dateObj.getMonth() + 1 + ).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")} ${String( + dateObj.getHours() + ).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart( + 2, + "0" + )}:${String(dateObj.getSeconds()).padStart(2, "0")}`; + const tooltip = [ + fullStamp, + org.lastSyncMessage, + org.lastSyncSha ? `SHA: ${org.lastSyncSha.slice(0, 7)}` : null, + ] + .filter(Boolean) + .join("\n"); + return ( + + {rel} + + ); + }; + const lastSyncColStyle = { + width: "170px", + fontVariantNumeric: "tabular-nums", + }; + + // Filter organizations based on search term + const filteredData = useMemo(() => { + return data.filter((org) => + org.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [data, searchTerm]); + + // Sort organizations + const sortedData = useMemo(() => { + if (!sortConfig.key) return filteredData; + + return [...filteredData].sort((a, b) => { + let aValue = a[sortConfig.key]; + let bValue = b[sortConfig.key]; + + // Convert dates to timestamps for comparison + if (sortConfig.key === "lastSyncDate") { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } + + if (aValue < bValue) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if (aValue > bValue) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + }, [filteredData, sortConfig]); + + // Handle column sorting + const handleSort = (key) => { + setSortConfig((prevConfig) => { + if (prevConfig.key === key) { + if (prevConfig.direction === "asc") { + return { key, direction: "desc" }; + } else if (prevConfig.direction === "desc") { + return { key: null, direction: null }; + } + } + return { key, direction: "asc" }; + }); + }; + + // Render sort icon + const renderSortIcon = (columnKey) => { + if (sortConfig.key !== columnKey) { + return ( + + ↕ + + ); + } + if (sortConfig.direction === "asc") { + return ; + } + if (sortConfig.direction === "desc") { + return ; + } + return ( + + ↕ + + ); + }; + + // Keep header checkbox indeterminate when some but not all rows are selected + useEffect(() => { + if (!headerCheckboxRef || !headerCheckboxRef.current) return; + const selectableCount = sortedData.filter((o) => !o.synced).length; + headerCheckboxRef.current.indeterminate = + selectedIds.size > 0 && selectedIds.size < selectableCount; + }, [selectedIds, sortedData]); + + // Prune selection when the displayed dataset changes (remove ids that no longer exist) + useEffect(() => { + setSelectedIds((prev) => { + const allowed = new Set( + sortedData.filter((o) => !o.synced).map((o) => o.id) + ); + const next = new Set([...prev].filter((id) => allowed.has(id))); + if (next.size === prev.size) return prev; + return next; + }); + }, [sortedData]); + + // Retrieve files for selected organizations + const retrieveFilesForSelected = async () => { + if (selectedIds.size === 0) return; + // map selected ids back to organization names using the current sorted/filtered dataset + const orgNames = sortedData + .filter((o) => selectedIds.has(o.id)) + .map((o) => o.name); + if (orgNames.length === 0) return; + setRetrieveResults(null); + setRetrieveMessage(null); + setRetrieveError(null); + setRetrievingFiles(true); + try { + const res = await fetch(withBasePath("/api/safe-settings/hub/import"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orgs: orgNames }), + }); + if (!res.ok) throw new Error(`Request failed (HTTP ${res.status})`); + const json = await res.json().catch(() => ({})); + if (Array.isArray(json.results)) { + setRetrieveResults(json.results); + const created = json.results.filter((r) => r.pr).length; + const skipped = json.results.filter((r) => r.skipped).map((r) => r.org); + const errors = json.results.filter((r) => r.error).length; + const parts = []; + if (created) + parts.push(`${created} PR${created > 1 ? "s" : ""} created`); + if (skipped.length) parts.push(`Skipped: ${skipped.join(", ")}`); + if (errors) parts.push(`${errors} error${errors > 1 ? "s" : ""}`); + setRetrieveMessage(parts.join(" • ") || "Retrieval completed"); + } else { + setRetrieveMessage(json.message || "Retrieval requested"); + } + } catch (e) { + setRetrieveError(e.message || String(e)); + } finally { + setRetrievingFiles(false); + } + }; + + return ( +
+ {/* Search Bar */} +
+
+
+
+ + + + setSearchTerm(e.target.value)} + /> +
+
+
+
+ + + + +
+
+
+
+ + {/* Reserved message area: keeps layout stable when messages appear */} +
+ {retrieveResults ? ( + retrieveResults.map((r) => ( +
+ {r.pr ? ( +
+ Imported {r.org}:{" "} + + {r.pr} + +
+ ) : r.skipped ? ( +
+ Skipping {r.org}: already present in hub +
+ ) : r.error ? ( +
+ {r.org}: {r.error} +
+ ) : null} +
+ )) + ) : ( + <> + {retrieveMessage && ( +
{retrieveMessage}
+ )} + {retrieveError && ( +
{retrieveError}
+ )} + + )} +
+ + {/* Table */} +
+ + + + + + + + + + + + {loading && ( + + + + )} + {!loading && error && ( + + + + )} + {!loading && !error && sortedData.length > 0 + ? sortedData.map((org) => { + return ( + + + + + + + + ); + }) + : !loading && + !error && ( + + + + )} + +
+ {/* compute selectable rows so header/select-all ignores already-imported orgs */} + { + const selectableCount = sortedData.filter( + (o) => !o.synced + ).length; + return ( + selectableCount > 0 && + selectedIds.size === selectableCount + ); + }, [sortedData, selectedIds])} + onChange={() => { + // toggle all selectable (non-synced) rows + setSelectedIds((prev) => { + const selectable = sortedData + .filter((o) => !o.synced) + .map((o) => o.id); + if (prev.size === selectable.length) return new Set(); + return new Set(selectable); + }); + }} + aria-label="Select all organizations" + /> + handleSort("name")} + > +
+
Organization Name
+
{renderSortIcon("name")}
+ + Showing {sortedData.length} of {data.length} organizations + +
+
+ Config Repo + + In Sync + handleSort("lastSyncDate")} + > + Last Sync + {renderSortIcon("lastSyncDate")} +
+ Loading organizations… +
+
+ {error} +
+
+ + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(org.id)) next.delete(org.id); + else next.add(org.id); + return next; + }) + } + aria-label={`Select ${org.name}`} + disabled={org.synced === true} + style={ + org.synced + ? { opacity: 0.45, cursor: "not-allowed" } + : {} + } + /> + + {org.name} + {org.synced && ( + Imported + )} + + {org.hasConfigRepo ? ( + + ✓ + + ) : ( + + NA + + )} + + {org.isInSync ? ( + + ✓ + + ) : ( + + ✗ + + )} + + {formatLastSync(org)} +
+ {searchTerm + ? `No organizations found matching "${searchTerm}"` + : "No organizations available"} +
+
+ + {/* Table Footer Info */} + {sortedData.length > 0 && ( +
+ + {searchTerm && `Filtered by: "${searchTerm}"`} + {sortConfig.key && ( + + • Sorted by:{" "} + {sortConfig.key === "name" + ? "Organization Name" + : "Last Safe-settings Sync"} + ({sortConfig.direction === "asc" ? "A-Z" : "Z-A"}) + + )} + +
+ )} +
+ ); +}; + +export default OrganizationsTable; diff --git a/ui/src/app/components/Safe-settings-hubContent.jsx b/ui/src/app/components/Safe-settings-hubContent.jsx new file mode 100644 index 000000000..0c0610890 --- /dev/null +++ b/ui/src/app/components/Safe-settings-hubContent.jsx @@ -0,0 +1,279 @@ +'use client'; +import { ChevronDownIcon, ChevronRightIcon, FileDirectoryIcon, FileIcon, SearchIcon } from '@primer/octicons-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useHydrated } from '../hooks/useHydrated'; +import { withBasePath } from '../utils/basePath'; + +// Match the left index width and reuse for the search input +const LEFT_COL_WIDTH = 320; + + +export default function SafeSettingsHubContent3b() { + const hydrated = useHydrated(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [rootTree, setRootTree] = useState(null); + const [search, setSearch] = useState(''); + const [expandedPaths, setExpandedPaths] = useState(() => new Set()); + const [selectedPath, setSelectedPath] = useState(null); + const [lastFetchedAt, setLastFetchedAt] = useState(null); + + const fetchData = () => { + if (!hydrated) return; + setLoading(true); setError(null); + fetch(withBasePath('/api/safe-settings/hub/content?fetchContent=true')) + .then(r => { + if (!r.ok) throw new Error(`Unable to retrieve safe-settings hub content (HTTP ${r.status})`); + return r.json(); + }) + .then(json => { setRootTree(json); setLastFetchedAt(new Date()); }) + .catch((error) => { setError("Unable to load content. Please try again later."); setRootTree(null); }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchData(); }, [hydrated]); + + const filterTree = useCallback((node) => { + if (!node) return null; + const term = search.toLowerCase(); + const matches = (n) => !term || (n.name && n.name.toLowerCase().includes(term)) || (n.path && n.path.toLowerCase().includes(term)); + if (node.type === 'file') { + return matches(node) ? node : null; + } + if (node.type === 'dir') { + const filteredEntries = (node.entries || []) + .map(child => filterTree(child)) + .filter(Boolean); + if (matches(node) || filteredEntries.length > 0) { + return { ...node, entries: filteredEntries }; + } + return null; + } + return null; + }, [search]); + + const filteredTree = useMemo(() => filterTree(rootTree), [rootTree, filterTree]); + + const displayTree = useMemo(() => { + if (!filteredTree) return null; + if (filteredTree.type === 'dir') { + const nameMatch = (n) => n && n.type === 'dir' && n.name && n.name.toLowerCase().includes('safe-settings'); + const immediate = (filteredTree.entries || []).find(nameMatch); + if (immediate) return immediate; + const findDescendant = (node, depth = 0, maxDepth = 3) => { + if (!node || node.type !== 'dir' || depth >= maxDepth) return null; + for (const child of node.entries || []) if (nameMatch(child)) return child; + for (const child of node.entries || []) if (child.type === 'dir') { + const found = findDescendant(child, depth + 1, maxDepth); + if (found) return found; + } + return null; + }; + const found = findDescendant(filteredTree, 0, 3); + if (found) return found; + } + return filteredTree; + }, [filteredTree]); + + useEffect(() => { if (!displayTree) return; setSelectedPath(prev => prev || displayTree.path); }, [displayTree]); + + const findNodeByPath = useCallback((node, path) => { + if (!node) return null; + if (node.path === path) return node; + if (node.type === 'dir') { + for (const child of node.entries || []) { + const found = findNodeByPath(child, path); + if (found) return found; + } + } + return null; + }, []); + + const selectedNode = useMemo(() => { + if (!displayTree || !selectedPath) return null; + return findNodeByPath(displayTree, selectedPath); + }, [displayTree, selectedPath, findNodeByPath]); + + const toggleDir = (path) => { setExpandedPaths(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; + + const formatTimeAgo = (iso) => { + if (!iso) return '—'; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return iso; + const diffSec = Math.floor((Date.now() - dt.getTime()) / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH} hour${diffH === 1 ? '' : 's'} ago`; + const diffD = Math.floor(diffH / 24); + if (diffD < 30) return `${diffD} day${diffD === 1 ? '' : 's'} ago`; + const diffM = Math.floor(diffD / 30); + if (diffM < 12) return diffM === 1 ? '1 month ago' : `${diffM} months ago`; + const diffY = Math.floor(diffD / 365); + if (diffY === 1) return 'last year'; + return `${diffY} years ago`; + }; + + const repoCount = useMemo(() => { + if (!rootTree) return '—'; + const rp = rootTree.reposProcessed || rootTree.repos || null; + if (!rp) return '—'; + if (Array.isArray(rp)) return rp.length; + if (typeof rp === 'object') return Object.keys(rp).length; + return '—'; + }, [rootTree]); + + const renderTree = (node, depth = 0) => { + if (!node) return null; + if (node.type === 'file') { + const selected = selectedPath === node.path; + return ( +
setSelectedPath(node.path)}> + + {node.name} +
+ ); + } + const expanded = expandedPaths.has(node.path); + const selected = selectedPath === node.path; + return ( +
+
+
{ toggleDir(node.path); setSelectedPath(node.path); }} className="d-inline-flex align-items-center"> + {expanded ? : } + + {node.name} +
+
+ {expanded && (node.entries || []).map(child => renderTree(child, depth + 1))} +
+ ); + }; + + const childrenForSelected = useMemo(() => { if (!selectedNode) return []; if (selectedNode.type === 'dir') return selectedNode.entries || []; return []; }, [selectedNode]); + + const fileContent = useMemo(() => { if (!selectedNode || selectedNode.type !== 'file') return null; return selectedNode.content || selectedNode.body || selectedNode.text || selectedNode.preview || null; }, [selectedNode]); + + const fileLines = useMemo(() => fileContent ? fileContent.split('\n') : [], [fileContent]); + const lineCount = fileLines.length; + const locCount = fileLines.filter(l => l.trim()).length; + const byteCount = useMemo(() => { + if (!fileContent) return 0; + try { return new TextEncoder().encode(fileContent).length; } catch (e) { return fileContent.length; } + }, [fileContent]); + + return ( +
+
+
+
+
+ + setSearch(e.target.value)} /> +
+ {selectedNode &&
{selectedNode.path}
} +
+
+
+
+ {/* edit button intentionally removed */} +
+
+
+ + {loading &&
Loading…
} + {error &&
{error}
} + {!loading && !displayTree &&
No entries
} + + {!loading && displayTree && ( +
+
+ {/* left tree */} + {displayTree.type === 'dir' && displayTree.name && displayTree.name.toLowerCase().includes('safe-settings') + ? (displayTree.entries || []).map(child => renderTree(child, 0)) + : renderTree(displayTree) + } +
+ +
+ {/* right content (dir/file view) */} + {selectedNode && selectedNode.type === 'dir' && ( +
+ {/* path rendered next to the filter at the top; removed empty toolbar to avoid extra top gap */} +
+ + + + + + + + + + {(childrenForSelected.length === 0) && ( + + )} + {childrenForSelected.map(child => ( + setSelectedPath(child.path)}> + + + + + ))} + +
NameCommit-MessageLast commit date
No entries
+ + {child.type === 'dir' ? : } + {child.name} + + {child.lastCommitMessage || '—'}{child.lastCommitAt ? formatTimeAgo(child.lastCommitAt) : '—'}
+
+
+ )} + + {selectedNode && selectedNode.type === 'file' && ( +
+ {/* path rendered next to the filter at the top; removed empty toolbar to avoid extra top gap */} +
+ {/* file header with border and rounded top, followed by a bordered code area with rounded bottom */} +
+
+
+
+ + +
+
+ +
+
+
+ {fileLines.map((_, i) =>
{i + 1}
)} +
+
+ {fileLines.length === 0 ? ( +
No content available
+ ) : ( + fileLines.map((ln, i) =>
{ln || ' '}
) + )} +
+
+
+
+
+
+ )} + + {!selectedNode && ( +
Select a folder or file from the left to view contents.
+ )} + +
+
+ )} + + {/* footer (items shown) removed */} +
+ ); +} diff --git a/ui/src/app/components/ThemeContext.jsx b/ui/src/app/components/ThemeContext.jsx new file mode 100644 index 000000000..75470143d --- /dev/null +++ b/ui/src/app/components/ThemeContext.jsx @@ -0,0 +1,71 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; + +const ThemeContext = createContext(); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +export const ThemeProvider = ({ children }) => { + // Always start with 'light' for SSR consistency + const [theme, setTheme] = useState('light'); + const [mounted, setMounted] = useState(false); + + // Only run on client side + useEffect(() => { + setMounted(true); + + // Migrate old theme key if it exists + const oldTheme = localStorage.getItem('safe-settings-theme'); + if (oldTheme && !localStorage.getItem('theme')) { + localStorage.setItem('theme', oldTheme); + localStorage.removeItem('safe-settings-theme'); + } + + // Get theme from localStorage + const savedTheme = localStorage.getItem('theme') || 'light'; + + // Set state (will trigger re-render with correct theme) + setTheme(savedTheme); + + // Apply to DOM immediately to prevent flash + document.documentElement.setAttribute('data-theme', savedTheme); + }, []); + + // Apply theme changes to DOM + useEffect(() => { + if (mounted) { + document.documentElement.setAttribute('data-theme', theme); + } + }, [theme, mounted]); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + const setSpecificTheme = (themeName) => { + setTheme(themeName); + localStorage.setItem('theme', themeName); + }; + + // Render children immediately - no hiding + return ( + + {children} + + ); +}; diff --git a/ui/src/app/components/ThemeToggle.jsx b/ui/src/app/components/ThemeToggle.jsx new file mode 100644 index 000000000..803c9d997 --- /dev/null +++ b/ui/src/app/components/ThemeToggle.jsx @@ -0,0 +1,17 @@ +'use client'; + +import { useTheme } from './ThemeContext'; + +export default function ThemeToggle() { + const { theme, toggleTheme, isDark } = useTheme(); + + return ( + + ); +} diff --git a/ui/src/app/components/TitleBar.css b/ui/src/app/components/TitleBar.css new file mode 100644 index 000000000..df856eff7 --- /dev/null +++ b/ui/src/app/components/TitleBar.css @@ -0,0 +1,174 @@ +/* TitleBar Component-Specific Styles */ + +/* Header styles */ +.title-header { + background: #333; + color: #fff; + min-height: 40px; /* Ensure consistent height */ +} + +/* Theme-specific header styles */ +[data-theme="light"] .title-header, +body.light-theme .title-header { + background: #333; + color: #fff; +} + +[data-theme="dark"] .title-header, +body.dark-theme .title-header { + background: #161b22; + color: #f0f6fc; +} + +/* Navigation bar - consistent height and styling */ +.title-nav { + min-height: 40px; /* Consistent nav height */ + border-bottom: 1px solid var(--border-color, #dee2e6) !important; + background: var(--bg-secondary, #f6f8fa); /* Default light theme background */ +} + +/* Data-theme selectors for immediate theme application */ +[data-theme="light"] .title-nav, +body.light-theme .title-nav { + background: #f6f8fa; + color: #24292f; + border-bottom: 1px solid #dee2e6 !important; +} + +[data-theme="dark"] .title-nav, +body.dark-theme .title-nav { + background: #22272e; + color: #f6f8fa; + border-bottom: 1px solid #666a6e !important; +} + +/* Theme toggle button */ +.theme-toggle-btn { + border: none !important; + background: transparent !important; + cursor: pointer !important; + padding: 2px !important; + border-radius: 6px !important; +} + +.theme-toggle-btn .theme-toggle-icon { + transition: color 0.15s; + color: lightgray; /* Default icon color */ +} + +/* Theme-specific toggle button styles */ +[data-theme="light"] .theme-toggle-btn .theme-toggle-icon, +body.light-theme .theme-toggle-btn .theme-toggle-icon { + color: #fff; +} + +[data-theme="dark"] .theme-toggle-btn .theme-toggle-icon, +body.dark-theme .theme-toggle-btn .theme-toggle-icon { + color: #f0f6fc; +} + +[data-theme="light"] .theme-toggle-btn:hover, +body.light-theme .theme-toggle-btn:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +[data-theme="dark"] .theme-toggle-btn:hover, +body.dark-theme .theme-toggle-btn:hover { + background: rgba(240, 246, 252, 0.1) !important; +} + +.theme-toggle-btn:hover .theme-toggle-icon, +.theme-toggle-btn:focus .theme-toggle-icon { + color: yellow; /* Hover icon color */ +} + +/* Navigation links */ +.nav-link-custom { + border: none; +} + +/* Light theme nav links */ +[data-theme="light"] .nav-link-custom, +body.light-theme .nav-link-custom { + color: #24292f !important; +} + +/* Dark theme nav links */ +[data-theme="dark"] .nav-link-custom, +body.dark-theme .nav-link-custom { + color: #f6f8fa; +} + +/* Navigation menu items */ +.nav-link.menu-hover { + border-radius: 5px !important; + margin: 10px 10px 9px 10px !important; + padding: 5px 10px !important; + transition: background 0.15s, color 0.15s; + border: 1px solid transparent !important; /* Invisible border to maintain box model */ +} + +.nav-link.menu-hover:hover { + background: var(--bg-accent) !important; + border-radius: 5px !important; + border: 1px solid transparent !important; /* Keep same border width */ +} + +/* Theme-specific hover colors */ +[data-theme="light"] .nav-link.menu-hover:hover, +body.light-theme .nav-link.menu-hover:hover { + background-color: #eaecef !important; +} + +[data-theme="dark"] .nav-link.menu-hover:hover, +body.dark-theme .nav-link.menu-hover:hover { + background-color: #30363d !important; +} + +/* Override Bootstrap's default nav-tabs border-radius */ +.nav-tabs .nav-link { + border-radius: 5px !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs .nav-link:hover { + border-radius: 5px !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs .nav-link:focus { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs .nav-link:active { + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.nav-tabs { + border-top: none; + border-bottom: none; +} + +.menu-hover.active { + background: transparent; + border: none !important; +} + +/* Active menu indicator */ +.menu-active-indicator { + position: absolute; + left: 0; + right: 0; + bottom: -10px; + height: 2px; + background: rgb(253, 140, 115); /* Orange-red underline color */ + border-radius: 1px; +} diff --git a/ui/src/app/components/TitleBar.jsx b/ui/src/app/components/TitleBar.jsx new file mode 100644 index 000000000..f3751ca8f --- /dev/null +++ b/ui/src/app/components/TitleBar.jsx @@ -0,0 +1,148 @@ +"use client"; +import { + GearIcon, + GlobeIcon, + ListUnorderedIcon, + MoonIcon, + NoteIcon, + SunIcon, +} from "@primer/octicons-react"; +import { usePathname } from "next/navigation"; +import { withBasePath } from "../utils/basePath"; +import { useTheme } from "./ThemeContext"; +import "./TitleBar.css"; + +export default function TitleBar() { + const pathname = usePathname(); + const { isDark, toggleTheme } = useTheme(); + + // Always render the TitleBar structure to prevent layout shift + return ( + <> +
+
+ + + + + + Safe-Settings Hub Dashboard + + +
+ +
+
+
+ + + ); +} diff --git a/ui/src/app/dashboard/env/page.jsx b/ui/src/app/dashboard/env/page.jsx new file mode 100644 index 000000000..6022b0b96 --- /dev/null +++ b/ui/src/app/dashboard/env/page.jsx @@ -0,0 +1,23 @@ +import TitleBar from "../../components/TitleBar"; +import EnvVariables from "../../components/EnvVariables"; + +export default function EnvVarsPage() { + return ( +
+ +
+
+

App Environment Settings

+

+ These are the current settings used by the app. Some values are hidden or + masked for security. +

+
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/app/dashboard/help/page.jsx b/ui/src/app/dashboard/help/page.jsx new file mode 100644 index 000000000..866aa7fae --- /dev/null +++ b/ui/src/app/dashboard/help/page.jsx @@ -0,0 +1,35 @@ +'use client'; + +import TitleBar from "../../components/TitleBar"; +import Link from "next/link"; +import HubOrgGraph from "../../components/HubOrgGraph"; + +export default function HelpPage() { + return ( +
+ +
+

Dashboard & Hub - Help

+

Quick guidance for the Safe-Settings Dashboard and Hub.

+ +

+

What is the Safe-Settings Dashboard

+

+ This UI provides status information for the Safe-Settings Hub feature. It is a read-first reporting and status tool that displays configuration state and import/sync status. +

+

How to get started

+

+ The Organizations page lists every Org where the Safe-Settings Hub is installed. You can use the Retrieve Settings button to perform an initial import from the selected organizations' config repositories. It reads files from the configured CONFIG_PATH in each organization's config repo and commits them into a single branch in the hub repository, then opens a pull request for review. This is intended for initial population or one-time imports — the action will skip organizations that already have content in the hub path. +

+

How to edit configuration

+

+ The dashboard is not a content editor. To change configuration you should edit files in your admin repository and follow the normal GitHub workflow: commit changes, open a pull request, get required approvers to review, and merge. After the PR is merged the dashboard will reflect the updated state. +

+
+

+ If you need more help, check the repository documentation or contact the maintainers. +

+
+
+ ); +} diff --git a/ui/src/app/dashboard/logs/page.jsx b/ui/src/app/dashboard/logs/page.jsx new file mode 100644 index 000000000..dc2bf1e3f --- /dev/null +++ b/ui/src/app/dashboard/logs/page.jsx @@ -0,0 +1,146 @@ +"use client" +import { useEffect, useState } from 'react' +import TitleBar from '../../components/TitleBar' +import { withBasePath } from '../../utils/basePath' + +export default function LogsPage () { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const logLevels = ['INFO', 'WARN', 'DEBUG', 'ERROR'] + const [selectedLevels, setSelectedLevels] = useState(new Set(logLevels)) + const [search, setSearch] = useState('') + const [syncOnly, setSyncOnly] = useState(false) + + useEffect(() => { + async function fetchLogs() { + try { + setLoading(true) + const response = await fetch(withBasePath('/api/safe-settings/hub/log?lines=100')) + if (!response.ok) throw new Error('Failed to fetch logs') + const data = await response.json() + setEntries(data.entries || []) + setError(null) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + fetchLogs() + }, []) + + const toggleLevel = (lvl) => { + const next = new Set(selectedLevels) + if (next.has(lvl)) next.delete(lvl) + else next.add(lvl) + setSelectedLevels(next) + } + + const filtered = entries.filter(e => { + // Filter by log level + if (!selectedLevels.has(e.level.toUpperCase())) return false + + // Filter by search term + if (search.trim() !== '' && !e.message.toLowerCase().includes(search.trim().toLowerCase())) return false + + // Filter by sync-only if enabled + if (syncOnly && !e.message.toLowerCase().includes('sync')) return false + + return true + }) + + return ( + <> + +
+
+
+
+

Safe Settings Hub-Sync Log

+

Last 100 entries from hubSyncHandler.log

+ {loading &&
Loading logs...
} + {error &&
Error: {error}
} +
+
+
+
+
+
+
Filter Options
+
+ Log Levels: +
+ {logLevels.map(lvl => ( + + ))} +
+
+
+ +
+
+ Search Message: + setSearch(e.target.value)} + style={{ maxWidth: 300 }} + /> +
+
+
+
+
+
+
+
Log Entries
+
+ + + + + + + + + + {filtered.map((row, i) => { + let levelClass = '' + if (row.level === 'ERROR') levelClass = 'log-error' + else if (row.level === 'WARN') levelClass = 'log-warn' + return ( + + + + + + ) + })} + +
TimestampLevelMessage
{row.timestamp || '-'}{row.level || 'UNKNOWN'}{row.message}
+ {filtered.length === 0 &&
No log entries match your filters.
} +
+
+
+
+
+ + ) +} diff --git a/ui/src/app/dashboard/organizations/page.jsx b/ui/src/app/dashboard/organizations/page.jsx new file mode 100644 index 000000000..596f9299f --- /dev/null +++ b/ui/src/app/dashboard/organizations/page.jsx @@ -0,0 +1,24 @@ +import TitleBar from "../../components/TitleBar"; +import OrganizationsTable from "../../components/OrganizationsTable"; + +export default function OrganizationsPage() { + return ( +
+ +
+
+

+ Organizations +

+

+ List all the Organizations where the Safe-Settings App is installed and the last time Safe-settings configurations were synced. +

+
+ +
+ +
+
+
+ ); +} diff --git a/ui/src/app/dashboard/page.jsx b/ui/src/app/dashboard/page.jsx new file mode 100644 index 000000000..7e4787fc5 --- /dev/null +++ b/ui/src/app/dashboard/page.jsx @@ -0,0 +1,25 @@ +import TitleBar from "../components/TitleBar"; +import { AlertIcon, ArrowRightIcon, CheckCircleIcon, GitCommitIcon, GitPullRequestIcon, GitMergeIcon, EyeIcon } from "@primer/octicons-react"; + +export default function DashboardPage() { + return ( +
+ +
+

Welcome to the Safe-Settings Hub Dashboard

+

Select a menu item above to get started.

+

+ This dashboard is a read-first reporting interface that displays configuration state and sync activity status for the Safe-Settings Hub.
+
It is not intended as the workflow for editing Safe-Settings Hub configuration content.

+ +
To make changes, please use the standard GitHub process for content updates:


+ Commit        + Pull Request        + Approve        + Merge         + +

+
+
+ ); +} diff --git a/ui/src/app/dashboard/safe-settings-hub/page.jsx b/ui/src/app/dashboard/safe-settings-hub/page.jsx new file mode 100644 index 000000000..56a4ef355 --- /dev/null +++ b/ui/src/app/dashboard/safe-settings-hub/page.jsx @@ -0,0 +1,25 @@ +import TitleBar from "../../components/TitleBar"; +import MasterAdminContents from "../../components/Safe-settings-hubContent"; + +export default function SafeSettingsHubConfigPage() { + return ( +
+ +
+
+

+ Safe-Settings Hub Content +

+

+ Listing files maintained by the Safe-Settings Global configuration (all ORG's). + Files are retrieved from `/api/safe-settings/hub/content`. +

+
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css new file mode 100644 index 000000000..0dbf38c60 --- /dev/null +++ b/ui/src/app/globals.css @@ -0,0 +1,277 @@ +/* Global Theme Variables */ +/* Default theme variables (light theme as default) */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +/* Theme variables based on data-theme attribute */ +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +[data-theme="dark"] { + --bg-primary: rgb(13, 17, 22); + --bg-secondary: #444444; + --bg-accent: #30363d; + --text-primary: #f0f6fc; + --text-secondary: #dddddd; + --border-color: #4d4d4d; +} + +/* Legacy support for body classes */ +body.light-theme { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-accent: #eaecef; + --text-primary: #24292f; + --text-secondary: #6c757d; + --border-color: #dee2e6; +} + +body.dark-theme { + --bg-primary: #161b22; + --bg-secondary: #444444; + --bg-accent: #30363d; + --text-primary: #f0f6fc; + --text-secondary: #b3b3b3; +} + +/* Global Theme Styles */ +/* Default body styling (light theme as default) */ +body { + background: var(--bg-primary, #fff) !important; + color: var(--text-primary, #24292f) !important; +} + +/* Theme-specific body styles using data-theme */ +[data-theme="light"] body, +body.light-theme { + background: #fff !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] body, +body.dark-theme { + background: rgb(24, 24, 24) !important; + color: var(--text-primary) !important; +} + +/* Global Main Element Theme */ +[data-theme="light"] main, +body.light-theme main { + background: #fff !important; + color: var(--text-primary) !important; +} + +[data-theme="dark"] main, +body.dark-theme main { + /* background: #161b22; */ + color: var(--text-primary) !important; +} + +[data-theme="light"] .nav-link, +body.light-theme .nav-link { + color: var(--text-primary) !important; +} + +[data-theme="dark"] .nav-link, +body.dark-theme .nav-link { + /* color: #f6f8fa !important; */ + color: #6c757d !important; +} + +/* title bar nav tabs */ +[data-theme="dark"] .nav-tabs, +body.dark-theme .nav-tabs { + background: #22272e; + border: none !important; +} + +[data-theme="light"] .nav-tabs, +body.light-theme .nav-tabs { + border: none !important; +} + +/* Apply theme variables to main element */ +main { + color: var(--text-primary) !important; + /* padding: 1rem; */ + border-radius: 12px !important; + /* margin-top: 1rem; */ +} + +/* Theme Utility Classes */ +.theme-bg-primary { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.theme-bg-secondary { + background-color: var(--bg-secondary); + color: var(--text-primary) !important; +} + +.theme-bg-accent { + background-color: var(--bg-accent); + color: var(--text-primary) !important; +} + +.theme-text-primary { + color: var(--text-primary) !important; +} + +.theme-text-secondary { + color: var(--text-secondary) !important; +} + +.theme-border { + border-color: var(--border-color) !important; + /* override bootstrap .border */ +} + +.border.theme-border, +.theme-border.border { + border-color: var(--border-color) !important; +} + +/* Global Font Utility Classes */ +.dark-font { + color: var(--text-primary) !important; +} + +.light-font { + color: var(--text-primary) !important; +} + +/* Organizations Table Styles */ +.ui-table .table { + background-color: var(--bg-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .table thead th { + background-color: var(--bg-secondary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; + font-weight: 600; +} + +.ui-table .table tbody td { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .table tbody tr:hover { + background-color: var(--bg-accent) !important; +} + +.ui-table .sortable-header:hover { + background-color: var(--bg-accent) !important; +} + +.ui-table .input-group-text { + background-color: var(--bg-secondary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .form-control { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.ui-table .form-control:focus { + background-color: var(--bg-primary); + color: var(--text-primary) !important; + border-color: var(--border-color) !important; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.ui-table .form-control::placeholder { + color: var(--text-secondary) !important; +} + +.list-group-item { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.list-group-item:hover { + background-color: var(--bg-accent) !important; +} + +.text-muted { + color: var(--text-secondary) !important; +} + +span.text-muted { + color: var(--text-secondary) !important; +} + +code { + color: var(--text-primary) !important; +} + +element { + color: var(--text-primary) !important; + background-color: var(--bg-secondary) !important; + border: 1px solid var(--border-color) !important; + margin-left: 10px !important; + gap: 1.5rem !important; +} + +.input-group-text { + background-color: var(--bg-secondary) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; +} + +.table { + border-radius: 12px !important; + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-color) !important; +} + +/* Env vars table dark mode override */ +[data-theme="dark"] .env-vars table, +body.dark-theme .env-vars table { + background-color: var(--bg-primary) !important; +} + +[data-theme="dark"] .env-vars thead th, +body.dark-theme .env-vars thead th { + background-color: var(--bg-secondary) !important; +} + +th { + font-weight: 600; + background-color: var(--bg-secondary) !important; +} + +tr td { + background-color: var(--bg-primary) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} + +.log-error { + color: #c00 !important; +} + +.log-warn { + color: #b8860b !important; +} \ No newline at end of file diff --git a/ui/src/app/hooks/useClientSafe.js b/ui/src/app/hooks/useClientSafe.js new file mode 100644 index 000000000..fabb0a510 --- /dev/null +++ b/ui/src/app/hooks/useClientSafe.js @@ -0,0 +1,45 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Custom hook to handle client-side mounting + * Helps prevent hydration mismatches by ensuring client-specific code + * only runs after the component has mounted on the client + */ +export const useIsClient = () => { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return isClient; +}; + +/** + * Custom hook for client-safe date formatting + * Returns a consistent format between server and client to prevent hydration issues + */ +export const useClientSafeDate = () => { + const isClient = useIsClient(); + + const formatDate = (dateString) => { + if (!isClient) { + // Server-side: return a simple format that matches potential client output + return new Date(dateString).toISOString().split('T')[0]; + } + + // Client-side: full formatting + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return { formatDate, isClient }; +}; diff --git a/ui/src/app/hooks/useHydrated.js b/ui/src/app/hooks/useHydrated.js new file mode 100644 index 000000000..7ab514a7f --- /dev/null +++ b/ui/src/app/hooks/useHydrated.js @@ -0,0 +1,18 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * Hook that ensures consistent rendering between server and client + * Prevents hydration mismatches by showing a simple version first, + * then upgrading to the full version after hydration + */ +export const useHydrated = () => { + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + return hydrated; +}; diff --git a/ui/src/app/layout.jsx b/ui/src/app/layout.jsx new file mode 100644 index 000000000..1104d2572 --- /dev/null +++ b/ui/src/app/layout.jsx @@ -0,0 +1,41 @@ +import './globals.css'; +import { ThemeProvider } from './components/ThemeContext'; + +// (Optional) Next.js App Router metadata API – safe to add +export const metadata = { + title: 'Safe Settings', + description: 'Safe Settings dashboard', + icons: { + icon: [ + { url: '/favicon.svg', type: 'image/svg+xml' }, + { url: '/favicon.ico', sizes: 'any' } + ], + apple: '/apple-touch-icon.png', + shortcut: '/favicon.ico' + } +}; + +export default function RootLayout({ children }) { + return ( + + + {/* Existing Bootstrap CSS */} + + {/* Favicon / icons */} + + + {/* Optional apple-touch-icon (provide file or remove link) */} + {/* */} + + + + + {children} + + + + ); +} \ No newline at end of file diff --git a/ui/src/app/not-found.jsx b/ui/src/app/not-found.jsx new file mode 100644 index 000000000..1ced869a6 --- /dev/null +++ b/ui/src/app/not-found.jsx @@ -0,0 +1,16 @@ +"use client"; +import TitleBar from "./components/TitleBar"; +import { withBasePath } from "./utils/basePath"; + +export default function NotFound() { + return ( +
+ +
+

404

+

Sorry, the page you are looking for does not exist.

+ Go to Dashboard +
+
+ ); +} diff --git a/ui/src/app/utils/basePath.js b/ui/src/app/utils/basePath.js new file mode 100644 index 000000000..ac223a054 --- /dev/null +++ b/ui/src/app/utils/basePath.js @@ -0,0 +1,36 @@ +/** + * URL prefix utility for handling deployment behind proxies + * Reads from Next.js basePath configuration + */ + +// Normalize URL prefix: add leading '/' if missing, treat '/' as empty for root +const normalizePrefix = (prefix) => { + if (!prefix || prefix === '/') return ''; + return prefix.startsWith('/') ? prefix : `/${prefix}`; +}; + +// Next.js automatically handles basePath for routing and asset loading +// We expose this for manual link construction (URL prefix in browser) +export const BASE_PATH = typeof window !== 'undefined' + ? (window.__NEXT_DATA__?.basePath || '/safe-settings') + : normalizePrefix(process.env.NEXT_PUBLIC_SAFE_SETTINGS_HUB_URL_PREFIX || process.env.SAFE_SETTINGS_HUB_URL_PREFIX || '/safe-settings'); + +/** + * Prepend base path to a URL + * Note: Next.js Link component and router.push already handle basePath automatically + * This is primarily for tags and manual URL construction + * @param {string} path - The path to prepend base path to + * @returns {string} The full path with base path + */ +export function withBasePath(path) { + // Ensure path starts with / + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + // If no base path, return as-is + if (!BASE_PATH) return normalizedPath; + + // Remove trailing slash from base path if present + const cleanBasePath = BASE_PATH.endsWith('/') ? BASE_PATH.slice(0, -1) : BASE_PATH; + + return `${cleanBasePath}${normalizedPath}`; +}