Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .github/scripts/open_docs_stub_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
open_docs_stub_pr.py

Opens a draft PR in pinecone-io/docs with TODO annotations for each
doc page flagged by check_docs_coverage.py. Runs only on the scheduled
drift-detection job, not on every PR.

Requirements:
- gh CLI (pre-installed on GitHub Actions ubuntu-latest runners)
- DOCS_TOKEN env var: a PAT with `repo` scope on pinecone-io/docs

Usage:
python .github/scripts/open_docs_stub_pr.py \
--gaps docs-gaps.json \
--docs-repo pinecone-io/docs \
--token "$DOCS_TOKEN"
"""

import argparse
import json
import os
import subprocess
import sys
import tempfile
from datetime import date
from pathlib import Path


def run(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> None:
print(f" $ {' '.join(str(c) for c in cmd)}", flush=True)
subprocess.run(cmd, cwd=cwd, env=env, check=True)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--gaps", default="docs-gaps.json")
parser.add_argument("--docs-repo", default="pinecone-io/docs")
parser.add_argument("--token", default=os.environ.get("DOCS_TOKEN", ""))
args = parser.parse_args()

if not args.token:
print("Error: --token or DOCS_TOKEN required", file=sys.stderr)
sys.exit(1)

with open(args.gaps) as f:
gaps = json.load(f)

if not gaps:
print("No gaps — nothing to open a PR for.")
return

today = date.today().isoformat()
branch = f"chore/sdk-drift-{today}"
pr_title = f"chore: update docs for SDK drift ({today})"

# Group gaps by page so we insert one comment block per page
pages: dict[str, list[dict]] = {}
for gap in gaps:
pages.setdefault(gap["doc_page"], []).append(gap)

gh_env = {**os.environ, "GH_TOKEN": args.token}

with tempfile.TemporaryDirectory() as tmpdir:
run(["gh", "repo", "clone", args.docs_repo, tmpdir, "--", "--depth=1"], env=gh_env)
run(["git", "checkout", "-b", branch], cwd=tmpdir)
run(["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], cwd=tmpdir)
run(["git", "config", "user.name", "github-actions[bot]"], cwd=tmpdir)

modified = []
for page_path, page_gaps in pages.items():
# Docs repo stores guides as docs/guides/<page>.mdx
mdx_path = Path(tmpdir) / f"{page_path}.mdx"
if not mdx_path.exists():
# Try without leading "guides/"
alt = Path(tmpdir) / "docs" / f"{page_path}.mdx"
if alt.exists():
mdx_path = alt
else:
print(f" Skipping {page_path} — .mdx not found in clone")
continue

content = mdx_path.read_text()

todos = []
for g in page_gaps:
msg = f"TODO(sdk-drift): `{g['symbol']}` {g['change']}"
if g.get("before") and g.get("after"):
msg += f"\n was: {g['before']}\n now: {g['after']}"
todos.append(f"{{/* {msg} */}}")

stub_block = "\n".join(todos)

# Insert after frontmatter (between second and third "---" markers)
parts = content.split("---", 2)
if len(parts) == 3:
updated = f"---{parts[1]}---\n\n{stub_block}\n{parts[2]}"
else:
updated = f"{stub_block}\n\n{content}"

mdx_path.write_text(updated)
modified.append(str(mdx_path.relative_to(tmpdir)))
print(f" Annotated {page_path}")

if not modified:
print("No .mdx files found in clone for the flagged pages — no PR opened.")
return

run(["git", "add"] + modified, cwd=tmpdir)
run(
["git", "commit", "-m", f"chore: add drift annotations ({today})"],
cwd=tmpdir,
)
run(["git", "push", "origin", branch], cwd=tmpdir, env=gh_env)

# Build PR body
body = ["## SDK drift detected", ""]
body.append(
"The weekly `docs-drift-detector` found SDK method changes that may need doc updates."
)
body.append("")
body.append("### Pages to review")
body.append("")
for page, page_gaps in pages.items():
body.append(f"**`{page}`**")
for g in page_gaps:
item = f"- [ ] `{g['symbol']}` ({g['change']})"
if g.get("before") and g.get("after"):
item += f": `{g['before']}` → `{g['after']}`"
body.append(item)
body.append("")
body.append(
"_Auto-generated stub. Mark items done as pages are updated. Close if not applicable._"
)

run(
[
"gh", "pr", "create",
"--repo", args.docs_repo,
"--head", branch,
"--title", pr_title,
"--body", "\n".join(body),
"--draft",
],
env=gh_env,
)

print(f"Draft PR opened: {pr_title}")


if __name__ == "__main__":
main()
147 changes: 147 additions & 0 deletions .github/workflows/spec-drift-detector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Detects when an OpenAPI spec change requires docs/SDK updates.
#
# On PR: comments affected guide pages + impacted SDKs (breaking changes first).
# Scheduled: diffs the two newest version snapshots; opens a draft PR in pinecone-io/docs.
#
# Deps: Python 3.12 + pyyaml. No other runtime dependencies.

name: spec-drift-detector

on:
pull_request:
types: [opened, synchronize, reopened]
paths: ["**/*.oas.yaml"]
schedule:
- cron: "0 9 * * 1" # Mondays 09:00 UTC
workflow_dispatch:

jobs:
detect:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install deps
run: pip install pyyaml

# ── PR mode: diff each changed spec file (base..head) ───────────────
- name: PR diff
if: github.event_name == 'pull_request'
run: |
BASE='${{ github.event.pull_request.base.sha }}'
HEAD='${{ github.event.pull_request.head.sha }}'
CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- '**/*.oas.yaml')
python - "$BASE" "$HEAD" $CHANGED <<'PY'
import json, sys, os
sys.path.insert(0, ".github/scripts")
import extract_spec_diff as e
base_ref, head_ref = sys.argv[1], sys.argv[2]
all_changes = []
for path in sys.argv[3:]:
b = e.load(e.git_show(base_ref, path))
h = e.load(e.git_show(head_ref, path))
svc = "_".join(os.path.basename(path).split("_")[:-1]) # db_data_2025-10.oas.yaml -> db_data
cs = e.diff(b, h)
for c in cs:
c["service"] = svc
c["version"] = path
all_changes += cs
json.dump(all_changes, open("spec-diff.json", "w"), indent=2)
print(f"{len(all_changes)} changes across changed specs")
PY

# ── Scheduled/manual: diff the two newest version dirs ──────────────
- name: Version-snapshot diff
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: |
python - <<'PY'
import glob, os, re, json, sys
sys.path.insert(0, ".github/scripts")
import extract_spec_diff as e
dirs = sorted([d for d in os.listdir(".") if re.fullmatch(r"\d{4}-\d{2}", d)], reverse=True)
if len(dirs) < 2:
json.dump([], open("spec-diff.json", "w")); print("need two version dirs"); raise SystemExit(0)
head_dir, base_dir = dirs[0], dirs[1]
all_changes = []
for hf in glob.glob(f"{head_dir}/*.oas.yaml"):
svc = "_".join(os.path.basename(hf).split("_")[:-1]) # strip trailing _<version>
matches = glob.glob(f"{base_dir}/{svc}_*.oas.yaml")
b = e.load(open(matches[0]).read()) if matches else {}
h = e.load(open(hf).read())
cs = e.diff(b, h)
for c in cs:
c["service"] = svc
c["version"] = head_dir
all_changes += cs
json.dump(all_changes, open("spec-diff.json", "w"), indent=2)
print(f"{base_dir} -> {head_dir}: {len(all_changes)} changes")
PY

- name: Check coverage
id: cov
run: |
python .github/scripts/check_spec_coverage.py \
--spec-diff spec-diff.json \
--manifest .github/spec-manifest.json \
--output spec-gaps.json

- name: Comment on PR
if: github.event_name == 'pull_request' && steps.cov.outputs.has_gaps == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs')
const gaps = JSON.parse(fs.readFileSync('spec-gaps.json', 'utf8'))
const breaking = gaps.filter(g => g.breaking)
const seen = new Set()
const byPage = {}
for (const g of gaps) {
const key = `${g.symbol}|${g.doc_page}`
if (seen.has(key)) continue; seen.add(key)
;(byPage[g.doc_page] = byPage[g.doc_page] || []).push(g)
}
const sdks = [...new Set(gaps.flatMap(g => g.sdks))].sort()
const lines = ['## Spec drift detected', '', 'This PR changes the OpenAPI surface. Likely doc/SDK impact:', '']
if (breaking.length) {
lines.push('### ⚠️ Breaking changes')
const bseen = new Set()
for (const g of breaking) {
const key = `${g.symbol}|${g.detail}`
if (bseen.has(key)) continue; bseen.add(key)
lines.push(`- \`${g.symbol}\` — ${g.detail}`)
}
lines.push('')
}
lines.push('### Pages to update')
for (const [page, items] of Object.entries(byPage)) {
lines.push(`**\`${page}\`**`)
for (const g of items) lines.push(`- [ ] \`${g.symbol}\` (${g.change})${g.breaking ? ' ⚠️' : ''}: ${g.detail}`)
lines.push('')
}
if (sdks.length) lines.push(`**Impacted SDKs:** ${sdks.join(', ')}`)
lines.push('', '_Auto-detected. Dismiss when docs are updated, or close if not applicable._')
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body: lines.join('\n'),
})

- name: Open docs stub PR
if: >
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
&& steps.cov.outputs.has_gaps == 'true'
env:
DOCS_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }}
run: |
python .github/scripts/open_docs_stub_pr.py \
--gaps spec-gaps.json \
--docs-repo pinecone-io/docs \
--token "$DOCS_TOKEN"