diff --git a/.github/actions/open-or-update-pr/action.yml b/.github/actions/open-or-update-pr/action.yml new file mode 100644 index 00000000..516549e7 --- /dev/null +++ b/.github/actions/open-or-update-pr/action.yml @@ -0,0 +1,69 @@ +name: Open or update pull request +description: >- + Commits the working-tree changes under `add-paths` to `branch`, pushes, and + opens or updates a pull request onto `base`. With no changes left, closes the + open pull request from `branch` (if any) and deletes the branch. If someone + other than the bot pushed to `branch`, leaves it alone and says so on the pull + request, so a reviewer's fix is never overwritten. Local replacement for + peter-evans/create-pull-request, which the org's Actions policy does not allow. + +inputs: + token: + description: GitHub App installation token with contents and pull-requests write + required: true + bot-slug: + description: App slug, used as the commit author `[bot]` + required: true + path: + description: Repository checkout to operate on, relative to the workspace + required: false + default: "." + add-paths: + description: Newline-separated git pathspecs to commit + required: true + branch: + description: Pull request branch (force-pushed) + required: true + base: + description: Pull request base branch + required: false + default: main + title: + description: Pull request title + required: true + body-path: + description: File containing the pull request body + required: true + commit-message: + description: Commit message + required: true + labels: + description: Comma-separated labels for the pull request (created if missing) + required: false + default: "" + +outputs: + operation: + description: created | updated | unchanged | closed | none | held + value: ${{ steps.pr.outputs.operation }} + url: + description: Pull request URL, when one exists + value: ${{ steps.pr.outputs.url }} + +runs: + using: composite + steps: + - id: pr + shell: bash + working-directory: ${{ inputs.path }} + env: + GH_TOKEN: ${{ inputs.token }} + BOT_SLUG: ${{ inputs.bot-slug }} + ADD_PATHS: ${{ inputs.add-paths }} + BRANCH: ${{ inputs.branch }} + BASE: ${{ inputs.base }} + TITLE: ${{ inputs.title }} + BODY_PATH: ${{ inputs.body-path }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + LABELS: ${{ inputs.labels }} + run: bash "$GITHUB_ACTION_PATH/open-or-update-pr.sh" diff --git a/.github/actions/open-or-update-pr/open-or-update-pr.sh b/.github/actions/open-or-update-pr/open-or-update-pr.sh new file mode 100755 index 00000000..8afd221c --- /dev/null +++ b/.github/actions/open-or-update-pr/open-or-update-pr.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# See action.yml. Runs inside the target repository checkout. +set -euo pipefail + +out() { echo "$1=$2" >> "$GITHUB_OUTPUT"; echo "$1: $2"; } + +# Pushes and fetches use the App token, not whatever actions/checkout persisted. +b64_auth="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" +git config --unset-all http.https://github.com/.extraheader 2>/dev/null || true +authed() { git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${b64_auth}" "$@"; } + +# Stage exactly the requested paths (deletions included). +paths=() +while IFS= read -r p; do [ -n "$p" ] && paths+=("$p"); done <<< "$ADD_PATHS" +git add -A -- "${paths[@]}" + +# Exit 2 means no such branch; anything else non-zero is a real failure. +remote_sha="$(authed ls-remote --exit-code --heads origin "$BRANCH" | cut -f1)" || { + status=$? + [ "$status" -eq 2 ] || { echo "::error::Couldn't read ${BRANCH} from origin (exit ${status})"; exit 1; } + remote_sha="" +} +[ -n "$remote_sha" ] && authed fetch -q origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" "+refs/heads/${BASE}:refs/remotes/origin/${BASE}" +# Only this repo's own branch: a fork PR can have a branch of the same name. +owner="$(gh repo view --json owner --jq .owner.login)" +pr_number="$(gh pr list --head "$BRANCH" --base "$BASE" --state open --json number,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"${owner}\")][0].number // empty")" + +# A commit someone else wrote, or rewrote, would be lost to the force-push or the branch +# delete below, so hold off and say so once on the PR. Rebuilding resumes when the PR is +# merged or closed. +held_marker="" +if [ -n "$remote_sha" ]; then + others="$(git log --format='%an%n%cn' "refs/remotes/origin/${BASE}..refs/remotes/origin/${BRANCH}" | grep -vxF "${BOT_SLUG}[bot]" || true)" + if [ -n "$others" ] && [ -z "$pr_number" ]; then + # No open PR: if the branch is exactly where a merged or closed PR left it, GitHub keeps + # those commits with that PR, so start over. Anything pushed since is someone's work. + ended_with="$(gh pr list --head "$BRANCH" --base "$BASE" --state all --json headRefOid,headRepositoryOwner \ + --jq ".[] | select(.headRepositoryOwner.login == \"${owner}\") | .headRefOid")" + grep -qx "$remote_sha" <<< "$ended_with" || { echo "::error::${BRANCH} has commits by someone other than the bot that no PR ended with; leaving it alone"; exit 1; } + authed push -q "--force-with-lease=refs/heads/${BRANCH}:${remote_sha}" origin --delete "$BRANCH" + remote_sha="" + others="" + fi + if [ -n "$others" ]; then + if [ -n "$pr_number" ] && ! gh pr view "$pr_number" --json comments --jq '.comments[].body' | grep -qF "$held_marker"; then + gh pr comment "$pr_number" --body "Someone other than the bot pushed to this branch, so the daily run no longer updates it. Merge or close this PR to let the next run pick up newer changes. +${held_marker}" + fi + out operation held + out url "$([ -n "$pr_number" ] && gh pr view "$pr_number" --json url --jq .url || true)" + exit 0 + fi +fi + +if git diff --cached --quiet; then + if [ -n "$pr_number" ]; then + gh pr close "$pr_number" --comment "Closing: \`${BASE}\` no longer differs from the source, so this change is no longer needed." + out operation closed + out url "$(gh pr view "$pr_number" --json url --jq .url)" + else + out operation none + out url "" + fi + [ -n "$remote_sha" ] && authed push -q "--force-with-lease=refs/heads/${BRANCH}:${remote_sha}" origin --delete "$BRANCH" + exit 0 +fi + +if [ -n "$remote_sha" ] && [ -n "$pr_number" ] && git diff --cached --quiet "refs/remotes/origin/${BRANCH}" --; then + out operation unchanged + out url "$(gh pr view "$pr_number" --json url --jq .url)" + exit 0 +fi + +# Build the commit from the index without moving HEAD or touching any local branch. +bot_id="$(gh api "/users/${BOT_SLUG}%5Bbot%5D" --jq .id)" +export GIT_AUTHOR_NAME="${BOT_SLUG}[bot]" GIT_COMMITTER_NAME="${BOT_SLUG}[bot]" +export GIT_AUTHOR_EMAIL="${bot_id}+${BOT_SLUG}[bot]@users.noreply.github.com" GIT_COMMITTER_EMAIL="${bot_id}+${BOT_SLUG}[bot]@users.noreply.github.com" +commit="$(git commit-tree "$(git write-tree)" -p HEAD -m "$COMMIT_MESSAGE")" +# The lease fails the push if anyone pushed since the check above. +authed push -q "--force-with-lease=refs/heads/${BRANCH}:${remote_sha}" origin "${commit}:refs/heads/${BRANCH}" + +label_args=() +IFS=',' read -ra labels <<< "$LABELS" +for label in ${labels[@]+"${labels[@]}"}; do + label="$(echo "$label" | xargs)" + [ -z "$label" ] && continue + gh label create "$label" >/dev/null 2>&1 || true # already exists + label_args+=(--add-label "$label") +done + +if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" --title "$TITLE" --body-file "$BODY_PATH" ${label_args[@]+"${label_args[@]}"} >/dev/null + out operation updated + out url "$(gh pr view "$pr_number" --json url --jq .url)" +else + url="$(gh pr create --head "$BRANCH" --base "$BASE" --title "$TITLE" --body-file "$BODY_PATH")" + [ ${#label_args[@]} -gt 0 ] && gh pr edit "$url" ${label_args[@]+"${label_args[@]}"} >/dev/null + out operation created + out url "$url" +fi diff --git a/.github/workflows/drift-sdk.yml b/.github/workflows/drift-sdk.yml new file mode 100644 index 00000000..ab0d38b1 --- /dev/null +++ b/.github/workflows/drift-sdk.yml @@ -0,0 +1,146 @@ +name: SDK drift + +# Drift checks 1-3: keeps the SDK's copies of apper values in step with apper, one rolling +# PR per check on bot/drift/. extract_apper.py reads the apper files; sync-sdk.mjs +# writes the SDK file. When a PR merges, publish-sdk-change-to-docs.yml opens the docs PR. +# Design: "Drift check automation design" (Google Doc). +# +# The docs bot's key reads apper, so it lives in the `drift` environment, which only main +# can use: a branch in this public repo can't run a changed workflow against it. Nothing +# from apper is printed except the values the SDK publishes anyway. + +on: + schedule: + - cron: "30 5 * * *" # Daily, 05:30 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + sync: + name: ${{ matrix.check }} + runs-on: ubuntu-latest + environment: drift + strategy: + fail-fast: false + matrix: + include: + - check: models + apper_files: backend/app/integrations/core/runtime_model_config.py + target: packages/sdk/src/modules/integrations.types.ts + title: "fix(types): sync InvokeLLM models with apper" + - check: tool-status + apper_files: backend/app/ai/models/message.py + target: packages/sdk/src/modules/agents.types.ts + title: "fix(types): sync agent tool call statuses with apper" + - check: connectors + apper_files: >- + backend/app/external_auth/integrations/config/connectors.json + backend/app/external_auth/models/constants.py + target: packages/sdk/src/modules/connectors.types.ts + title: "docs(connectors): sync the Available connectors table with apper" + concurrency: + group: sdk-drift-${{ matrix.check }} + cancel-in-progress: false + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + - name: apper read token + id: apper-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.DOCS_BOT_APP_ID }} + private-key: ${{ secrets.DOCS_BOT_PRIVATE_KEY }} + owner: base44-dev + repositories: apper + permission-contents: read + + - name: Fetch the apper files + env: + GH_TOKEN: ${{ steps.apper-token.outputs.token }} + FILES: ${{ matrix.apper_files }} + run: | + set -euo pipefail + for rel in $FILES; do + mkdir -p "$RUNNER_TEMP/apper/$(dirname "$rel")" + gh api -H "Accept: application/vnd.github.raw+json" "/repos/base44-dev/apper/contents/$rel?ref=main" > "$RUNNER_TEMP/apper/$rel" + test -s "$RUNNER_TEMP/apper/$rel" || { echo "::error::Fetched an empty $rel"; exit 1; } + done + + - name: Sync from apper + env: + CHECK: ${{ matrix.check }} + run: | + set -euo pipefail + python3 packages/sdk/scripts/drift/extract_apper.py --apper "$RUNNER_TEMP/apper" "$CHECK" > "$RUNNER_TEMP/extract.json" + node packages/sdk/scripts/drift/sync-sdk.mjs --check "$CHECK" --source "$RUNNER_TEMP/extract.json" --summary "$RUNNER_TEMP/summary.md" + { + cat "$RUNNER_TEMP/summary.md" + echo + echo "_Opened by the SDK drift workflow. It runs daily and updates this PR while the drift lasts. When it merges, the docs PR opens in mintlify-docs and waits for the next SDK release._" + } > "$RUNNER_TEMP/pr-body.md" + + - name: javascript-sdk write token + id: sdk-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.BASE44_GITHUB_ACTIONS_APP_ID }} + private-key: ${{ secrets.BASE44_GITHUB_ACTIONS_APP_PRIVATE_KEY }} + owner: base44 + repositories: javascript-sdk + permission-contents: write + permission-pull-requests: write + + - name: Open or update the pull request + id: pr + uses: ./.github/actions/open-or-update-pr + with: + token: ${{ steps.sdk-token.outputs.token }} + bot-slug: ${{ steps.sdk-token.outputs.app-slug }} + add-paths: ${{ matrix.target }} + branch: bot/drift/${{ matrix.check }} + base: main + title: ${{ matrix.title }} + body-path: ${{ runner.temp }}/pr-body.md + commit-message: ${{ matrix.title }} + labels: drift + + - name: Report + env: + OPERATION: ${{ steps.pr.outputs.operation }} + URL: ${{ steps.pr.outputs.url }} + run: | + { + echo "## ${{ matrix.check }}" + echo + echo "Pull request: \`${OPERATION:-none}\` ${URL}" + echo + cat "$RUNNER_TEMP/summary.md" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Open or comment on the failure issue + if: ${{ failure() }} + env: + GH_TOKEN: ${{ github.token }} + CHECK: ${{ matrix.check }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="SDK drift check failing: $CHECK" + gh label create drift --color 0E8A16 --description "Opened by the docs drift workflows" --force + issue=$(gh issue list --state open --label drift --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -1) + if [ -n "$issue" ]; then + gh issue comment "$issue" --body "Failed again: $RUN_URL" + else + gh issue create --title "$title" --label drift --body "The daily SDK drift check \`$CHECK\` failed: $RUN_URL + + Close this issue once it's fixed; the next failure opens a new one." + fi diff --git a/.github/workflows/publish-sdk-change-to-docs.yml b/.github/workflows/publish-sdk-change-to-docs.yml new file mode 100644 index 00000000..9a3ceb11 --- /dev/null +++ b/.github/workflows/publish-sdk-change-to-docs.yml @@ -0,0 +1,280 @@ +name: Publish SDK Change to Docs + +# Opens a mintlify-docs PR with the docs impact of one SDK commit: regenerates the +# reference before and after it, and applies only that diff to the published pages +# (English and locale mirrors). Runs when a drift PR (bot/drift/*, opened by the +# base44-github-actions app) merges; can be dispatched for any commit. +# +# The docs PR is marked as waiting for the SDK release that contains the commit, so +# mintlify-docs' release gate holds it until then. +# +# Triggered by the push to main rather than the PR event so it runs on main and can use +# the `drift` environment, which holds the docs bot key and only main can use. + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + head_sha: + description: "javascript-sdk commit whose docs impact to publish (default: main)" + required: false + default: "" + type: string + base_sha: + description: "Commit to compare against (default: first parent of head)" + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: publish-sdk-change-to-docs-${{ inputs.head_sha || github.sha }} + cancel-in-progress: false + +env: + DOCS_REPO: base44-dev/mintlify-docs + DOCS_DIR: mintlify-docs + SDK_BOT: base44-github-actions[bot] + +jobs: + source: + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.pr.outputs.publish }} + url: ${{ steps.pr.outputs.url }} + steps: + - name: Checkout for wix gateway proxy + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + sparse-checkout: .github + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + # Branch names aren't proof of anything, so the PR's author must be the bot too. + - name: Was this a drift PR? + id: pr + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT" = workflow_dispatch ]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + pr=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" \ + --jq "[.[] | select(.merged_at != null and (.head.ref | startswith(\"bot/drift/\")) and .user.login == \"${SDK_BOT}\")][0] // empty") + if [ -z "$pr" ]; then + echo "Not a merged drift PR; nothing to publish." + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "url=$(jq -r .html_url <<< "$pr")" >> "$GITHUB_OUTPUT" + fi + + publish: + needs: source + if: needs.source.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: drift + permissions: + contents: read + issues: write + + steps: + - name: Checkout javascript-sdk + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Resolve commit range + id: range + env: + INPUT_HEAD: ${{ inputs.head_sha }} + INPUT_BASE: ${{ inputs.base_sha }} + # On a push, everything it brought, so a PR merged as several commits publishes whole. + PUSH_BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + head="$(git rev-parse --verify "${INPUT_HEAD:-HEAD}^{commit}")" + base="$(git rev-parse --verify "${INPUT_BASE:-${PUSH_BEFORE:-${head}^1}}^{commit}")" + # Only code already on main runs here, next to the docs bot key. + for sha in "$head" "$base"; do + git merge-base --is-ancestor "$sha" origin/main || { echo "::error::${sha} is not on main"; exit 1; } + done + git merge-base --is-ancestor "$base" "$head" || { echo "::error::${base} is not an ancestor of ${head}"; exit 1; } + # Both regenerations use this checkout's node_modules, so neither side may differ from it. + deps=(package.json package-lock.json packages/sdk/package.json) + if ! git diff --quiet "$base" "$head" -- "${deps[@]}" || ! git diff --quiet HEAD "$head" -- "${deps[@]}"; then + echo "::error::Dependencies differ between ${base:0:7}, ${head:0:7} and this checkout; regenerate with create-docs-local by hand" + exit 1 + fi + subject="$(git log -1 --format=%s "$head")" + # "fix(types): do X (#12)" -> "do X" + plain="$(printf '%s' "$subject" | sed -E 's/^[a-z]+(\([^)]*\))?!?: //; s/ \(#[0-9]+\)$//')" + { + echo "head=${head}" + echo "short=${head:0:7}" + echo "base=${base}" + echo "subject=${subject}" + echo "plain_subject=${plain}" + } >> "$GITHUB_OUTPUT" + echo "Publishing docs impact of ${base:0:7}..${head:0:7}: ${subject}" + + - name: Generate reference before and after + env: + BASE: ${{ steps.range.outputs.base }} + HEAD: ${{ steps.range.outputs.head }} + run: | + set -euo pipefail + for side in base head; do + sha="$BASE"; [ "$side" = head ] && sha="$HEAD" + git worktree add --detach "$RUNNER_TEMP/$side" "$sha" + ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/$side/node_modules" + npm run --prefix "$RUNNER_TEMP/$side/packages/sdk" create-docs + test -d "$RUNNER_TEMP/$side/packages/sdk/docs/content" + done + + - name: mintlify-docs write token + id: docs-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.DOCS_BOT_APP_ID }} + private-key: ${{ secrets.DOCS_BOT_PRIVATE_KEY }} + owner: base44-dev + repositories: mintlify-docs + permission-contents: write + permission-pull-requests: write + + # The docs PR waits for the SDK release only if mintlify-docs main requires the + # release gate's status. Without that rule, a reviewer could merge it early. + - name: Check mintlify-docs holds waiting PRs + env: + GH_TOKEN: ${{ steps.docs-token.outputs.token }} + run: | + set -euo pipefail + ruleset="$(gh api "repos/${DOCS_REPO}/rules/branches/main" --jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] | index("SDK released") != null')" + classic="$(gh api "repos/${DOCS_REPO}/branches/main" --jq '((.protection.required_status_checks.contexts // []) | index("SDK released")) != null')" + if [ "$ruleset" != true ] && [ "$classic" != true ]; then + echo "::error::mintlify-docs main doesn't require the 'SDK released' status, so a docs PR waiting for this release could merge early. Add that required check first." + exit 1 + fi + + - name: Checkout mintlify-docs + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ${{ env.DOCS_REPO }} + ref: main + token: ${{ steps.docs-token.outputs.token }} + path: ${{ env.DOCS_DIR }} + + - name: Apply only this change to the published docs + id: patch + run: | + set -euo pipefail + node packages/sdk/scripts/drift/scoped-docs-patch.mjs \ + --base "$RUNNER_TEMP/base/packages/sdk/docs/content" \ + --head "$RUNNER_TEMP/head/packages/sdk/docs/content" \ + --target "$GITHUB_WORKSPACE/$DOCS_DIR" \ + --apply \ + --summary "$RUNNER_TEMP/summary.md" + + - name: Compose PR body + env: + HEAD: ${{ steps.range.outputs.head }} + SHORT: ${{ steps.range.outputs.short }} + BASE: ${{ steps.range.outputs.base }} + SUBJECT: ${{ steps.range.outputs.subject }} + SOURCE_PR: ${{ needs.source.outputs.url }} + run: | + set -euo pipefail + { + echo "Docs impact of [\`base44/javascript-sdk@${SHORT}\`](https://github.com/base44/javascript-sdk/commit/${HEAD}), compared against \`${BASE:0:7}\`:" + echo + echo '```' + echo "$SUBJECT" + echo '```' + if [ -n "$SOURCE_PR" ]; then + echo + echo "Source PR: ${SOURCE_PR}" + fi + echo + cat "$RUNNER_TEMP/summary.md" + echo + echo "This PR waits for the SDK release that includes \`${SHORT}\`: the \"SDK released\" check stays pending until then. Approve it and enable auto-merge, and it merges once the release is out." + echo + echo "" + echo + echo "_Opened by the \`publish-sdk-change-to-docs\` workflow in javascript-sdk. Generated output: review the rendered page, not the raw diff._" + } > "$RUNNER_TEMP/pr-body.md" + + # One branch per source commit. + - name: Open pull request + id: pr + uses: ./.github/actions/open-or-update-pr + with: + path: ${{ env.DOCS_DIR }} + token: ${{ steps.docs-token.outputs.token }} + bot-slug: ${{ steps.docs-token.outputs.app-slug }} + add-paths: | + developers/references/sdk/docs + */developers/references/sdk/docs/* + branch: bot/sdk-docs/${{ steps.range.outputs.short }} + base: main + title: "docs(sdk): ${{ steps.range.outputs.plain_subject }} (javascript-sdk@${{ steps.range.outputs.short }})" + body-path: ${{ runner.temp }}/pr-body.md + commit-message: | + docs(sdk): ${{ steps.range.outputs.plain_subject }} + + Source: https://github.com/base44/javascript-sdk/commit/${{ steps.range.outputs.head }} + labels: drift, waits-for:sdk-release + + - name: Report + env: + CHANGED: ${{ steps.patch.outputs.changed }} + OPERATION: ${{ steps.pr.outputs.operation }} + URL: ${{ steps.pr.outputs.url }} + run: | + { + echo "## Publish SDK Change to Docs" + echo + echo "- Docs changed: \`${CHANGED}\`" + echo "- Pull request: \`${OPERATION:-none}\` ${URL}" + echo + cat "$RUNNER_TEMP/summary.md" + } >> "$GITHUB_STEP_SUMMARY" + + # The usual cause is a published page that's behind its generated source in the same + # lines; the SDK regeneration drift check (check 8) brings it level at the next release. + - name: Open or comment on the failure issue + if: ${{ failure() }} + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Publishing an SDK change to the docs failed" + gh label create drift --color 0E8A16 --description "Opened by the docs drift workflows" --force + issue=$(gh issue list --state open --label drift --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -1) + if [ -n "$issue" ]; then + gh issue comment "$issue" --body "Failed again: $RUN_URL" + else + gh issue create --title "$title" --label drift --body "Opening the docs PR for an SDK drift change failed: $RUN_URL + + Close this issue once it's fixed; the next failure opens a new one." + fi diff --git a/packages/sdk/scripts/drift/extract_apper.py b/packages/sdk/scripts/drift/extract_apper.py new file mode 100644 index 00000000..4cd1b87e --- /dev/null +++ b/packages/sdk/scripts/drift/extract_apper.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Read the values the SDK mirrors from apper source, with Python's ast module (apper is +never imported or run), and print them as JSON for sync-sdk.mjs. + + models InvokeLLM model presets apper offers: the RuntimeModel enum minus + _LEGACY_RUNTIME_MODELS and "automatic", in enum order + tool-status values of the status Literal on apper's ToolCall model + connectors connectors in the seed catalog that aren't behind a feature flag or in beta + +Gated connectors are counted but not named: this repo is public, and naming them would +announce unreleased integrations. + +Usage: extract_apper.py --apper DIR {models,tool-status,connectors} +""" + +from __future__ import annotations + +import argparse +import ast +import json +import pathlib +import re +import sys + +MODELS_FILE = "backend/app/integrations/core/runtime_model_config.py" +MESSAGE_FILE = "backend/app/ai/models/message.py" +SEED_FILE = "backend/app/external_auth/integrations/config/connectors.json" +CONSTANTS_FILE = "backend/app/external_auth/models/constants.py" +FLAG_CHECK = re.compile(r"flag|require_feature", re.IGNORECASE) + + +def module(apper: pathlib.Path, rel: str) -> tuple[str, ast.Module]: + text = (apper / rel).read_text() + try: + return text, ast.parse(text) + except SyntaxError: + # The traceback would print apper source into this public repo's logs. + raise SystemExit(f"Can't parse {rel}") from None + + +def assigned(tree: ast.Module, name: str) -> ast.expr: + for node in tree.body: + target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0] if isinstance(node, ast.Assign) else None + if isinstance(target, ast.Name) and target.id == name: + return node.value + raise SystemExit(f"No module-level {name}") + + +def models(apper: pathlib.Path) -> dict: + text, tree = module(apper, MODELS_FILE) + # Nothing in this file is gated today. If a model ever is, this check can't tell + # which, so it stops rather than publish a gated model in the public SDK. + if FLAG_CHECK.search(text): + raise SystemExit(f"{MODELS_FILE} now mentions feature flags; update extract_apper.py to leave gated models out") + enum = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "RuntimeModel") + members = {s.targets[0].id: s.value.value for s in enum.body if isinstance(s, ast.Assign) and isinstance(s.value, ast.Constant)} + legacy_node = assigned(tree, "_LEGACY_RUNTIME_MODELS") + legacy = {e.attr for e in legacy_node.args[0].elts} # frozenset({RuntimeModel.X, ...}) + unknown = legacy - members.keys() + if unknown: + raise SystemExit(f"_LEGACY_RUNTIME_MODELS names members RuntimeModel doesn't have: {sorted(unknown)}") + return {"offered": [v for k, v in members.items() if k not in legacy and v != "automatic"]} + + +def tool_status(apper: pathlib.Path) -> dict: + _, tree = module(apper, MESSAGE_FILE) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "ToolCall") + field = next(s for s in cls.body if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name) and s.target.id == "status") + literal = next( + n for n in ast.walk(field.annotation) + if isinstance(n, ast.Subscript) and isinstance(n.value, ast.Name) and n.value.id == "Literal" + ) + values = literal.slice.elts if isinstance(literal.slice, ast.Tuple) else [literal.slice] + return {"values": [v.value for v in values]} + + +def connectors(apper: pathlib.Path) -> dict: + seed = json.loads((apper / SEED_FILE).read_text()) + _, tree = module(apper, CONSTANTS_FILE) + flagged = {k.value for k in assigned(tree, "INTEGRATION_FEATURE_FLAGS").keys} + public, gated = [], 0 + for entry in seed: + display = entry.get("display", {}) + if entry["integration_type"] in flagged or display.get("display_status") == "beta": + gated += 1 + else: + public.append({"type": entry["integration_type"], "name": display["display_name"]}) + return {"connectors": public, "gated": gated} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--apper", type=pathlib.Path, required=True) + parser.add_argument("check", choices=["models", "tool-status", "connectors"]) + args = parser.parse_args() + extract = {"models": models, "tool-status": tool_status, "connectors": connectors}[args.check] + json.dump(extract(args.apper), sys.stdout) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/sdk/scripts/drift/scoped-docs-patch.mjs b/packages/sdk/scripts/drift/scoped-docs-patch.mjs new file mode 100644 index 00000000..c43fc78f --- /dev/null +++ b/packages/sdk/scripts/drift/scoped-docs-patch.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/** + * Applies the diff between two generated `docs/content` trees (before and after + * an SDK change) to the published pages in a mintlify-docs checkout: English and + * every locale mirror. Modified pages only; added or removed pages need the full + * pipeline (docs.json nav), so they are refused, as is a patch that does not + * apply cleanly. + * + * Usage: + * node packages/sdk/scripts/drift/scoped-docs-patch.mjs --base --head \ + * --target [--apply] [--summary ] + * + * Exit codes: 0 applied or nothing to apply, 1 validation/apply failure. + * When GITHUB_OUTPUT is set, emits `changed` and `files`. + */ + +import { execFileSync } from "node:child_process"; +import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SDK_DOCS_PATH = "developers/references/sdk/docs"; +// Never published by the copy pipeline. +const IGNORED = new Set(["README.mdx"]); +const MAX_PATCH_IN_SUMMARY = 20_000; + +class PatchError extends Error {} + +function parseArgs(argv) { + const opts = { base: null, head: null, target: null, apply: false, summary: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + if (i + 1 >= argv.length) throw new PatchError(`${arg} requires a value`); + return resolve(argv[++i]); + }; + switch (arg) { + case "--base": opts.base = next(); break; + case "--head": opts.head = next(); break; + case "--target": opts.target = next(); break; + case "--summary": opts.summary = next(); break; + case "--apply": opts.apply = true; break; + default: throw new PatchError(`Unknown argument: ${arg}`); + } + } + for (const k of ["base", "head", "target"]) { + if (!opts[k]) throw new PatchError(`--${k} is required`); + if (!existsSync(opts[k]) || !statSync(opts[k]).isDirectory()) throw new PatchError(`--${k} is not a directory: ${opts[k]}`); + } + if (!existsSync(join(opts.target, "docs.json"))) throw new PatchError(`${opts.target} has no docs.json; is it a mintlify-docs checkout?`); + return opts; +} + +/** Run git; `okCodes` lists exit codes that are not failures (diff exits 1 on differences). */ +function git(args, { cwd, okCodes = [0] } = {}) { + try { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (err) { + if (okCodes.includes(err.status)) return err.stdout ?? ""; + throw new PatchError(`git ${args.join(" ")} failed (exit ${err.status}):\n${(err.stderr || "").trim()}`); + } +} + +/** + * Copy both generated trees into one scratch dir as `base/` and `head/` so git + * sees short relative paths. That fixes the patch headers to `a/base/` + * and `b/head/`, which `git apply -p2` reduces to ``. + */ +function stageTrees(base, head) { + const work = mkdtempSync(join(tmpdir(), "scoped-docs-")); + cpSync(base, join(work, "base"), { recursive: true }); + cpSync(head, join(work, "head"), { recursive: true }); + return work; +} + +/** Files that differ between `base/` and `head/` in the scratch dir, as {status, path}. */ +function diffTrees(work) { + const out = git(["diff", "--no-index", "--name-status", "--", "base", "head"], { cwd: work, okCodes: [0, 1] }); + const entries = []; + for (const line of out.split("\n").filter(Boolean)) { + const [status, ...paths] = line.split("\t"); + const rel = paths[paths.length - 1].replace(/^(head|base)\//, ""); + if (IGNORED.has(rel)) continue; + entries.push({ status: status[0], path: rel }); + } + return entries; +} + +function localePrefixes(target) { + const docs = JSON.parse(readFileSync(join(target, "docs.json"), "utf8")); + const languages = docs.navigation?.languages; + if (!Array.isArray(languages) || languages.length === 0) { + throw new PatchError("docs.json has no navigation.languages; the locale layout this script expects is gone"); + } + const locales = languages.filter((l) => !l.default).map((l) => l.language); + if (locales.length === 0) throw new PatchError("docs.json lists no non-default locales; expected mirrors like de/, es/"); + return ["", ...locales.map((l) => `${l}/`)]; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + const work = stageTrees(opts.base, opts.head); + const entries = diffTrees(work); + const summaryLines = []; + const emit = (kv) => { + if (!process.env.GITHUB_OUTPUT) return; + appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(kv).map(([k, v]) => `${k}=${v}`).join("\n") + "\n"); + }; + const finish = (text) => { + if (opts.summary) writeFileSync(opts.summary, text); + process.stdout.write(text); + }; + + if (entries.length === 0) { + emit({ changed: "false", files: "" }); + finish("No generated reference page differs between the two SDK states, so there is nothing to publish.\n"); + return 0; + } + + const notModified = entries.filter((e) => e.status !== "M"); + if (notModified.length > 0) { + throw new PatchError( + "This change adds or removes reference pages, which also needs a docs.json nav update. " + + "Run `npm run create-docs-local` and open that PR by hand instead:\n " + + notModified.map((e) => `${e.status} ${e.path}`).join("\n "), + ); + } + + // Per-file patch with `a/base/` / `b/head/` headers; `git apply -p2` reduces them to ``. + const relPatch = entries + .map((e) => git(["diff", "--no-index", "--", `base/${e.path}`, `head/${e.path}`], { cwd: work, okCodes: [0, 1] })) + .join(""); + const patchPath = join(work, "changes.patch"); + writeFileSync(patchPath, relPatch); + + const prefixes = localePrefixes(opts.target); + const failures = []; + for (const prefix of prefixes) { + try { + git(["apply", "--check", "-p2", `--directory=${prefix}${SDK_DOCS_PATH}`, patchPath], { cwd: opts.target }); + } catch (err) { + failures.push(`${prefix || "(en)"}: ${err.message.split("\n").slice(1).join(" ").trim()}`); + } + } + if (failures.length > 0) { + throw new PatchError( + "The change does not apply cleanly to the published docs. The published page probably has " + + "unpublished drift in the same lines. Regenerate with `npm run create-docs-local` and open that PR by hand.\n " + + failures.join("\n "), + ); + } + + if (opts.apply) { + for (const prefix of prefixes) { + git(["apply", "-p2", `--directory=${prefix}${SDK_DOCS_PATH}`, patchPath], { cwd: opts.target }); + } + const touched = git(["status", "--porcelain", "--", SDK_DOCS_PATH, `*/${SDK_DOCS_PATH}/*`], { cwd: opts.target }) + .split("\n").filter(Boolean).length; + const expected = entries.length * prefixes.length; + if (touched !== expected) { + throw new PatchError(`Expected ${expected} modified files after applying (${entries.length} page(s) x ${prefixes.length} locale dirs), found ${touched}`); + } + } + + emit({ changed: "true", files: entries.map((e) => e.path).join(",") }); + summaryLines.push(`**Pages changed (${entries.length}):**`); + for (const e of entries) summaryLines.push(`- \`${SDK_DOCS_PATH}/${e.path}\``); + summaryLines.push(""); + summaryLines.push(`Applied to the English pages and ${prefixes.length - 1} locale mirrors (${prefixes.slice(1).map((p) => `\`${p.slice(0, -1)}\``).join(", ")}). Only the lines this SDK change produced are included; other unpublished reference drift is left alone.`); + summaryLines.push(""); + summaryLines.push("
Change applied to each copy"); + summaryLines.push(""); + summaryLines.push("~~~~diff"); + summaryLines.push(relPatch.length > MAX_PATCH_IN_SUMMARY ? relPatch.slice(0, MAX_PATCH_IN_SUMMARY) + "\n... (truncated)" : relPatch.trimEnd()); + summaryLines.push("~~~~"); + summaryLines.push(""); + summaryLines.push("
"); + finish(summaryLines.join("\n") + "\n"); + if (!opts.apply) console.error("dry run: pass --apply to write into the target"); + return 0; +} + +// Only run as a CLI when executed directly, so tests can import the functions. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + process.exit(main()); + } catch (err) { + if (err instanceof PatchError) { + console.error(`error: ${err.message}`); + process.exit(1); + } + throw err; + } +} diff --git a/packages/sdk/scripts/drift/sync-sdk.mjs b/packages/sdk/scripts/drift/sync-sdk.mjs new file mode 100644 index 00000000..b6b49fc8 --- /dev/null +++ b/packages/sdk/scripts/drift/sync-sdk.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/** + * Drift checks 1, 2 and 3: brings the SDK's copies of apper values up to date from the + * JSON that extract_apper.py prints. + * + * models `InvokeLLMParams.model` union and its `Options:` JSDoc line + * tool-status `AgentMessageToolCall.status` union + * connectors the "Available connectors" JSDoc table in connectors.types.ts + * + * The two unions only ever gain values: removing one breaks TypeScript code that uses it, + * so values apper dropped are listed in the summary for a person to decide. The Options + * line, which readers see in the docs, lists exactly what apper offers. + * + * Usage: + * node sync-sdk.mjs --check --source --summary + * + * Writes the target file in place. Exit codes: 0 done (in sync or synced), 1 failure. + * When GITHUB_OUTPUT is set, emits `changed`. + */ + +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MODULES = resolve(dirname(fileURLToPath(import.meta.url)), "../../src/modules"); +// Safe to embed unescaped in a TS string literal and a Markdown code span. +const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const UNION_RE = /^([ \t]*)model\?: ('[^']*'(?: \| '[^']*')*);$/; +const OPTIONS_RE = /^([ \t]*\* Options: )(`"[^"]*"`(?:, `"[^"]*"`)*)$/; +const MAX_OPTIONS_DISTANCE = 20; +const STATUS_RE = /^([ \t]*)status: ("[^"]*"(?: \| "[^"]*")*);$/; +const CONNECTOR_ROW_RE = /^ \* \| (.+?) \| `([^`]+)` \|$/; +// Names that differ from the seed catalog's display_name, used for new rows only. +const DISPLAY_NAMES = { github: "GitHub", one_drive: "Microsoft OneDrive" }; +// Connector PRs aren't auto-merged: the seed catalog can't show whether a connector is +// visible to every user, and connectors added through the admin API exist only in the +// database. With a readable live catalog, PRs that only add connectors could auto-merge. + +export class SyncError extends Error {} + +function singleLine(lines, re, what, from = 0, to = lines.length) { + const hits = []; + for (let i = from; i < to; i++) if (re.test(lines[i])) hits.push(i); + if (hits.length !== 1) throw new SyncError(`Expected one ${what}, found ${hits.length}`); + return hits[0]; +} + +function checkValues(values, what) { + const bad = values.filter((v) => !SAFE_VALUE.test(v)); + if (bad.length) throw new SyncError(`Unexpected characters in ${what}: ${bad.join(", ")}`); +} + +/** Union gains apper's new values; the Options line becomes exactly what apper offers. */ +export function syncModels(ts, offered) { + checkValues(offered, "models"); + if (offered.length < 3) throw new SyncError(`Only ${offered.length} offered models; the extractor probably broke`); + const lines = ts.split("\n"); + const u = singleLine(lines, UNION_RE, "`model?:` union"); + const o = singleLine(lines, OPTIONS_RE, "`* Options:` line", Math.max(0, u - MAX_OPTIONS_DISTANCE), u); + const [, indent, unionText] = UNION_RE.exec(lines[u]); + const prefix = OPTIONS_RE.exec(lines[o])[1]; + const current = [...unionText.matchAll(/'([^']*)'/g)].map((m) => m[1]); + const union = [...offered, ...current.filter((m) => !offered.includes(m))]; + lines[u] = `${indent}model?: ${union.map((m) => `'${m}'`).join(" | ")};`; + lines[o] = `${prefix}${offered.map((m) => `\`"${m}"\``).join(", ")}`; + return { + text: lines.join("\n"), + added: offered.filter((m) => !current.includes(m)), + retired: current.filter((m) => !offered.includes(m)), + }; +} + +/** `AgentMessageToolCall.status` gains apper's new values, appended. */ +export function syncToolStatus(ts, values) { + checkValues(values, "statuses"); + const lines = ts.split("\n"); + const start = singleLine(lines, /^export interface AgentMessageToolCall \{$/, "AgentMessageToolCall interface"); + const end = lines.indexOf("}", start); + const s = singleLine(lines, STATUS_RE, "`status:` union in AgentMessageToolCall", start, end); + const [, indent, unionText] = STATUS_RE.exec(lines[s]); + const current = [...unionText.matchAll(/"([^"]*)"/g)].map((m) => m[1]); + const added = values.filter((v) => !current.includes(v)); + lines[s] = `${indent}status: ${[...current, ...added].map((v) => `"${v}"`).join(" | ")};`; + return { text: lines.join("\n"), added, retired: current.filter((v) => !values.includes(v)) }; +} + +/** Rows for new public connectors at their alphabetical place; rows apper no longer lists removed. */ +export function syncConnectors(ts, connectors) { + const lines = ts.split("\n"); + const heading = singleLine(lines, /^ \* ## Available connectors$/, "Available connectors heading"); + const header = lines.indexOf(" * | Service | Type identifier |", heading); + if (header < 0 || lines[header + 1] !== " * |---|---|") throw new SyncError("The Available connectors table header moved"); + let end = header + 2; + while (CONNECTOR_ROW_RE.test(lines[end])) end++; + const rows = lines.slice(header + 2, end).map((l) => { + const [, name, type] = CONNECTOR_ROW_RE.exec(l); + return { name, type }; + }); + if (connectors.length < 0.7 * rows.length) { + throw new SyncError(`apper lists ${connectors.length} public connectors but the table has ${rows.length}; the extractor probably broke`); + } + const publicTypes = new Set(connectors.map((c) => c.type)); + const documented = new Set(rows.map((r) => r.type)); + const removed = rows.filter((r) => !publicTypes.has(r.type)).map((r) => r.type); + const kept = rows.filter((r) => publicTypes.has(r.type)); + const added = []; + for (const c of connectors.filter((c) => !documented.has(c.type))) { + checkValues([c.type], "connector types"); + const name = DISPLAY_NAMES[c.type] ?? c.name; + if (!name || name.includes("|")) throw new SyncError(`Unusable display name for ${c.type}`); + const at = kept.findIndex((r) => r.name.localeCompare(name, "en", { sensitivity: "base" }) > 0); + kept.splice(at < 0 ? kept.length : at, 0, { name, type: c.type }); + added.push({ name, type: c.type }); + } + lines.splice(header + 2, end - header - 2, ...kept.map((r) => ` * | ${r.name} | \`${r.type}\` |`)); + return { text: lines.join("\n"), added, removed }; +} + +const CHECKS = { + models: { + file: "integrations.types.ts", + run: (ts, src) => syncModels(ts, src.offered), + summary: ({ added, retired }) => [ + added.length ? `**Added to the \`InvokeLLMParams.model\` union:** ${added.map(code).join(", ")}` : null, + "The `Options:` line now lists exactly the models apper offers.", + retired.length ? `**No longer offered by apper, kept in the union** so existing code keeps compiling: ${retired.map(code).join(", ")}. Removing them is a breaking change for a person to decide.` : null, + ], + }, + "tool-status": { + file: "agents.types.ts", + run: (ts, src) => syncToolStatus(ts, src.values), + summary: ({ added, retired }) => [ + added.length ? `**Added to the \`AgentMessageToolCall.status\` union:** ${added.map(code).join(", ")}` : null, + retired.length ? `**No longer in apper, kept in the union** so existing code keeps compiling: ${retired.map(code).join(", ")}. Removing them is a breaking change for a person to decide.` : null, + ], + }, + connectors: { + file: "connectors.types.ts", + run: (ts, src) => syncConnectors(ts, src.connectors), + summary: ({ added, removed }, src) => [ + added.length ? `**Added to the Available connectors table:** ${added.map((c) => `${c.name} (${code(c.type)})`).join(", ")}. Check each display name.` : null, + removed.length ? `**Removed from the table** (no longer in apper's catalog, or now behind a flag): ${removed.map(code).join(", ")}` : null, + src.gated ? `${src.gated} connectors in apper's catalog are behind a feature flag or in beta, so they're left out.` : null, + ], + }, +}; + +function code(v) { + return `\`${v}\``; +} + +function main() { + const args = Object.fromEntries( + process.argv.slice(2).reduce((pairs, a, i, all) => (a.startsWith("--") ? [...pairs, [a.slice(2), all[i + 1]]] : pairs), []), + ); + const check = CHECKS[args.check]; + if (!check || !args.source || !args.summary) throw new SyncError("Usage: sync-sdk.mjs --check --source --summary "); + const source = JSON.parse(readFileSync(args.source, "utf8")); + const path = join(MODULES, check.file); + const before = readFileSync(path, "utf8"); + const result = check.run(before, source); + const changed = result.text !== before; + if (changed) writeFileSync(path, result.text); + const lines = changed ? check.summary(result, source).filter(Boolean) : ["Already in sync with apper."]; + writeFileSync(args.summary, lines.join("\n\n") + "\n"); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); + process.stdout.write(lines.join("\n\n") + "\n"); + return 0; +} + +// Only run as a CLI when executed directly, so tests can import the functions. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + process.exit(main()); + } catch (err) { + if (err instanceof SyncError) { + console.error(`error: ${err.message}`); + process.exit(1); + } + throw err; + } +} diff --git a/packages/sdk/tests/unit/drift/scoped-docs-patch.test.ts b/packages/sdk/tests/unit/drift/scoped-docs-patch.test.ts new file mode 100644 index 00000000..70a86a99 --- /dev/null +++ b/packages/sdk/tests/unit/drift/scoped-docs-patch.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT = fileURLToPath(new URL('../../../scripts/drift/scoped-docs-patch.mjs', import.meta.url)); +const DOCS_PATH = 'developers/references/sdk/docs'; +const LOCALES = ['de', 'fr']; +const PAGE = 'type-aliases/integrations.mdx'; + +const basePage = ['# InvokeLLM', '', 'intro', '', '', '', 'Options: ``"a"``, ``"b"``', '', '', ''].join('\n'); +const headPage = basePage.replace('"a" | "b"', '"a" | "b" | "c"').replace('``"a"``, ``"b"``', '``"a"``, ``"b"``, ``"c"``'); + +function write(root: string, rel: string, content: string) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), content); +} + +function git(cwd: string, ...args: string[]) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +type RunResult = { status: number; stdout: string; stderr: string; outputs: Record }; + +function run(args: string[], cwd: string): RunResult { + const outputFile = join(cwd, 'github_output'); + writeFileSync(outputFile, ''); + let status = 0, stdout = '', stderr = ''; + try { + stdout = execFileSync(process.execPath, [SCRIPT, ...args], { cwd, env: { ...process.env, GITHUB_OUTPUT: outputFile }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err: any) { + status = err.status; stdout = err.stdout ?? ''; stderr = err.stderr ?? ''; + } + const outputs: Record = {}; + for (const line of readFileSync(outputFile, 'utf8').split('\n').filter(Boolean)) { + const i = line.indexOf('='); + outputs[line.slice(0, i)] = line.slice(i + 1); + } + return { status, stdout, stderr, outputs }; +} + +describe('scoped-docs-patch', () => { + let dir: string, base: string, head: string, target: string; + const allCopies = () => ['', ...LOCALES.map((l) => `${l}/`)].map((p) => `${p}${DOCS_PATH}/${PAGE}`); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'scoped-docs-')); + base = join(dir, 'base'); + head = join(dir, 'head'); + target = join(dir, 'mintlify-docs'); + + // Generated trees before and after an SDK change. README.mdx differs too + // but is never published, so it must be ignored. One page is unchanged. + for (const [root, page, readme] of [[base, basePage, 'readme v1'], [head, headPage, 'readme v2']] as const) { + write(root, PAGE, page); + write(root, 'interfaces/agents.mdx', 'unchanged\n'); + write(root, 'README.mdx', readme); + } + + // A mintlify-docs checkout whose published pages match the base state. + const docsJson = { navigation: { languages: [{ language: 'en', default: true, tabs: [] }, ...LOCALES.map((l) => ({ language: l, tabs: [] }))] } }; + write(target, 'docs.json', JSON.stringify(docsJson)); + for (const rel of allCopies()) write(target, rel, basePage); + git(target, 'init', '-q'); + git(target, 'add', '-A'); + git(target, '-c', 'user.name=t', '-c', 'user.email=t@example.com', 'commit', '-q', '-m', 'published'); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + test('applies only the changed page to English and every locale mirror', () => { + const summary = join(dir, 'summary.md'); + const res = run(['--base', base, '--head', head, '--target', target, '--apply', '--summary', summary], dir); + expect(res.status, res.stderr).toBe(0); + expect(res.outputs).toEqual({ changed: 'true', files: PAGE }); + for (const rel of allCopies()) expect(readFileSync(join(target, rel), 'utf8')).toBe(headPage); + const status = git(target, 'status', '--porcelain').trimEnd().split('\n'); + expect(status).toHaveLength(allCopies().length); + expect(status.every((l) => l.startsWith(' M '))).toBe(true); + const text = readFileSync(summary, 'utf8'); + expect(text).toContain(`${DOCS_PATH}/${PAGE}`); + expect(text).toContain('+Options: ``"a"``, ``"b"``, ``"c"``'); + expect(text).not.toContain('README'); + }); + + test('dry run checks every copy but writes nothing', () => { + const res = run(['--base', base, '--head', head, '--target', target], dir); + expect(res.status, res.stderr).toBe(0); + expect(res.outputs.changed).toBe('true'); + expect(git(target, 'status', '--porcelain')).toBe(''); + }); + + test('refuses when the published page already differs in the same lines', () => { + expect(run(['--base', base, '--head', head, '--target', target, '--apply'], dir).status).toBe(0); + const res = run(['--base', base, '--head', head, '--target', target, '--apply'], dir); + expect(res.status).toBe(1); + expect(res.stderr).toMatch(/does not apply cleanly/); + expect(res.stderr).toMatch(/patch does not apply/); + }); + + test('refuses when a locale copy is missing rather than patching the rest', () => { + rmSync(join(target, 'fr', DOCS_PATH, PAGE)); + const res = run(['--base', base, '--head', head, '--target', target, '--apply'], dir); + expect(res.status).toBe(1); + expect(res.stderr).toMatch(/fr\//); + expect(git(target, 'status', '--porcelain')).toMatch(/^ D /); + expect(git(target, 'status', '--porcelain').split('\n').filter((l) => l.startsWith(' M'))).toHaveLength(0); + }); + + test('reports nothing to publish when only ignored files differ', () => { + const same = join(dir, 'same'); + cpSync(base, same, { recursive: true }); + writeFileSync(join(same, 'README.mdx'), 'readme changed'); + const res = run(['--base', base, '--head', same, '--target', target, '--apply'], dir); + expect(res.status, res.stderr).toBe(0); + expect(res.outputs).toEqual({ changed: 'false', files: '' }); + expect(res.stdout).toMatch(/nothing to publish/); + expect(git(target, 'status', '--porcelain')).toBe(''); + }); + + test('refuses page additions and removals because they need a nav update', () => { + write(head, 'type-aliases/new-page.mdx', 'new\n'); + const res = run(['--base', base, '--head', head, '--target', target, '--apply'], dir); + expect(res.status).toBe(1); + expect(res.stderr).toMatch(/adds or removes reference pages/); + expect(res.stderr).toMatch(/A type-aliases\/new-page\.mdx/); + expect(git(target, 'status', '--porcelain')).toBe(''); + }); + + test('rejects a target that is not a mintlify-docs checkout or has no locales', () => { + expect(run(['--base', base, '--head', head, '--target', dir], dir).stderr).toMatch(/no docs.json/); + write(target, 'docs.json', JSON.stringify({ navigation: { languages: [{ language: 'en', default: true }] } })); + expect(run(['--base', base, '--head', head, '--target', target], dir).stderr).toMatch(/no non-default locales/); + }); +}); diff --git a/packages/sdk/tests/unit/drift/sync-sdk.test.ts b/packages/sdk/tests/unit/drift/sync-sdk.test.ts new file mode 100644 index 00000000..bbe5da59 --- /dev/null +++ b/packages/sdk/tests/unit/drift/sync-sdk.test.ts @@ -0,0 +1,132 @@ +import { describe, test, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { syncModels, syncToolStatus, syncConnectors, SyncError } from "../../../scripts/drift/sync-sdk.mjs"; + +const EXTRACT = fileURLToPath(new URL("../../../scripts/drift/extract_apper.py", import.meta.url)); + +const INTEGRATIONS = [ + "export interface InvokeLLMParams {", + " /** Model.", + " *", + ' * Options: `"a"`, `"old"`', + " */", + " model?: 'a' | 'old';", + "}", +].join("\n"); + +const AGENTS = [ + "export interface Other {", + ' status: "x";', + "}", + "export interface AgentMessageToolCall {", + ' status: "running" | "success";', + "}", +].join("\n"); + +const CONNECTORS = [ + " * ## Available connectors", + " *", + " * | Service | Type identifier |", + " * |---|---|", + " * | Airtable | `airtable` |", + " * | Slack | `slack` |", + " * | Zoom | `zoom` |", + " *", +].join("\n"); + +describe("syncModels", () => { + test("adds offered models, keeps retired ones in the union, and lists only offered ones as options", () => { + const { text, added, retired } = syncModels(INTEGRATIONS, ["a", "b", "c"]); + expect(text).toContain("model?: 'a' | 'b' | 'c' | 'old';"); + expect(text).toContain('Options: `"a"`, `"b"`, `"c"`'); + expect(added).toEqual(["b", "c"]); + expect(retired).toEqual(["old"]); + }); + + test("refuses values that would break the TS literal and suspiciously short lists", () => { + expect(() => syncModels(INTEGRATIONS, ["a", "b", "bad'quote"])).toThrow(SyncError); + expect(() => syncModels(INTEGRATIONS, ["a"])).toThrow(SyncError); + }); +}); + +describe("syncToolStatus", () => { + test("appends new values only inside AgentMessageToolCall", () => { + const { text, added, retired } = syncToolStatus(AGENTS, ["running", "error"]); + expect(text).toContain(' status: "running" | "success" | "error";'); + expect(text).toContain(' status: "x";'); + expect(added).toEqual(["error"]); + expect(retired).toEqual(["success"]); + }); +}); + +describe("syncConnectors", () => { + test("inserts new connectors alphabetically, applies display-name overrides, removes dropped ones", () => { + const { text, added, removed } = syncConnectors(CONNECTORS, [ + { type: "airtable", name: "Airtable" }, + { type: "github", name: "GitHub API" }, + { type: "zoom", name: "Zoom" }, + ]); + const rows = text.split("\n").filter((l: string) => l.startsWith(" * | ") && l.includes("`")); + expect(rows).toEqual([" * | Airtable | `airtable` |", " * | GitHub | `github` |", " * | Zoom | `zoom` |"]); + expect(added).toEqual([{ name: "GitHub", type: "github" }]); + expect(removed).toEqual(["slack"]); + }); + + test("refuses a catalog that suddenly lists far fewer connectors", () => { + expect(() => syncConnectors(CONNECTORS, [{ type: "airtable", name: "Airtable" }])).toThrow(SyncError); + }); +}); + +describe("extract_apper.py", () => { + function apper(files: Record) { + const root = mkdtempSync(join(tmpdir(), "apper-")); + for (const [rel, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + } + return root; + } + const extract = (root: string, check: string) => + JSON.parse(execFileSync("python3", [EXTRACT, "--apper", root, check], { encoding: "utf8", env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" } })); + + test("offered models leave out legacy models and automatic", () => { + const root = apper({ + "backend/app/integrations/core/runtime_model_config.py": [ + "class RuntimeModel(str, Enum):", + ' AUTOMATIC = "automatic"', + ' NEW = "new_model"', + ' OLD = "old_model"', + "_LEGACY_RUNTIME_MODELS: frozenset[RuntimeModel] = frozenset({RuntimeModel.OLD})", + ].join("\n"), + }); + expect(extract(root, "models")).toEqual({ offered: ["new_model"] }); + }); + + test("tool statuses come from the Literal inside Annotated", () => { + const root = apper({ + "backend/app/ai/models/message.py": [ + "class ToolCall(BaseModel):", + " status: Annotated[", + ' Literal["running", "success"], _PUBLIC,', + ' ] = Field(default="running")', + ].join("\n"), + }); + expect(extract(root, "tool-status")).toEqual({ values: ["running", "success"] }); + }); + + test("flagged and beta connectors are counted, not named", () => { + const root = apper({ + "backend/app/external_auth/integrations/config/connectors.json": JSON.stringify([ + { integration_type: "slack", display: { display_name: "Slack" } }, + { integration_type: "secret", display: { display_name: "Secret" } }, + { integration_type: "early", display: { display_name: "Early", display_status: "beta" } }, + ]), + "backend/app/external_auth/models/constants.py": 'INTEGRATION_FEATURE_FLAGS: dict[str, FeatureFlag] = {"secret": FeatureFlag.X}', + }); + expect(extract(root, "connectors")).toEqual({ connectors: [{ type: "slack", name: "Slack" }], gated: 2 }); + }); +});