mirror of
https://github.com/langchain-ai/docs.git
synced 2026-08-27 02:41:59 -04:00
381 lines
12 KiB
Python
381 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Flag external integrations that may warrant hosted docs.
|
|
|
|
Reads scripts/data/integration_external_docs.yaml, fetches current monthly
|
|
download counts, and creates Linear issues for any entry at or above
|
|
50,000 downloads/month (override with --threshold).
|
|
|
|
Component curation bars (chat, tools, sandboxes, retrievers, embeddings,
|
|
vectorstores, and document_loaders: 50k) are included in the issue for
|
|
context.
|
|
|
|
Skips creating a duplicate when an open Linear issue already matches the
|
|
stable title for that integration.
|
|
|
|
Usage (from repo root):
|
|
|
|
# Dry-run (no Linear calls):
|
|
uv run python scripts/flag_hosted_docs_candidates.py
|
|
|
|
# Create Linear issues (requires LINEAR_API_KEY and LINEAR_TEAM_KEY):
|
|
uv run python scripts/flag_hosted_docs_candidates.py --create
|
|
|
|
uv run python scripts/flag_hosted_docs_candidates.py --create --threshold 50000
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import requests
|
|
import yaml
|
|
|
|
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
_REPO_ROOT = _SCRIPT_DIR.parent
|
|
_EXTERNAL_DOCS_PATH = _SCRIPT_DIR / "data" / "integration_external_docs.yaml"
|
|
|
|
# Linear early-warning threshold for external integrations.
|
|
FLAG_THRESHOLD = 50_000
|
|
|
|
# Component curation bars for hosted guides (used in issue context).
|
|
COMPONENT_CURATION_BARS = {
|
|
"chat": 50_000,
|
|
"tools": 50_000,
|
|
"sandboxes": 50_000,
|
|
"retrievers": 50_000,
|
|
"embeddings": 50_000,
|
|
"vectorstores": 50_000,
|
|
"document_loaders": 50_000,
|
|
}
|
|
|
|
_LINEAR_URL = "https://api.linear.app/graphql"
|
|
_HTTP_HEADERS = {"User-Agent": "langchain-docs-hosted-docs-flag/1.0"}
|
|
_REQUEST_TIMEOUT = 20
|
|
|
|
# Import download helpers from the refresh script (same package dir).
|
|
sys.path.insert(0, str(_SCRIPT_DIR))
|
|
import refresh_integration_downloads as rid # noqa: E402
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Candidate:
|
|
language: str
|
|
component: str
|
|
name: str
|
|
package: str
|
|
registry: str
|
|
docs_url: str
|
|
downloads: int
|
|
curation_bar: int
|
|
|
|
@property
|
|
def issue_title(self) -> str:
|
|
return (
|
|
f"Consider hosting {self.component} docs for {self.name} "
|
|
f"({self.downloads:,}/mo)"
|
|
)
|
|
|
|
@property
|
|
def search_term(self) -> str:
|
|
# Stable fragment for dedupe across changing download counts.
|
|
return f"Consider hosting {self.component} docs for {self.name}"
|
|
|
|
@property
|
|
def meets_curation_bar(self) -> bool:
|
|
return self.downloads >= self.curation_bar
|
|
|
|
|
|
def _load_external_entries() -> list[tuple[str, str, dict[str, Any]]]:
|
|
if not _EXTERNAL_DOCS_PATH.is_file():
|
|
return []
|
|
data = yaml.safe_load(_EXTERNAL_DOCS_PATH.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
return []
|
|
entries: list[tuple[str, str, dict[str, Any]]] = []
|
|
for language, components in data.items():
|
|
if not isinstance(components, dict):
|
|
continue
|
|
for component, items in components.items():
|
|
if not isinstance(items, list):
|
|
continue
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
entries.append((str(language), str(component), item))
|
|
return entries
|
|
|
|
|
|
def _package_and_registry(
|
|
language: str, item: dict[str, Any]
|
|
) -> tuple[Optional[str], Optional[str]]:
|
|
if language == "javascript":
|
|
npm = item.get("npm")
|
|
if isinstance(npm, str) and npm.strip() and not npm.strip().startswith("-"):
|
|
return npm.strip(), "npm"
|
|
pypi = item.get("pypi")
|
|
if isinstance(pypi, str) and pypi.strip() and not pypi.strip().startswith("-"):
|
|
return pypi.strip(), "pypi"
|
|
return None, None
|
|
|
|
|
|
def collect_candidates(threshold: int | None = None) -> list[Candidate]:
|
|
package_cache: dict[tuple[str, str], int] = {}
|
|
candidates: list[Candidate] = []
|
|
flag_threshold = FLAG_THRESHOLD if threshold is None else threshold
|
|
|
|
for language, component, item in _load_external_entries():
|
|
name = item.get("name")
|
|
docs_url = item.get("docs_url")
|
|
if not isinstance(name, str) or not name.strip():
|
|
continue
|
|
if not isinstance(docs_url, str) or not docs_url.strip():
|
|
continue
|
|
package, registry = _package_and_registry(language, item)
|
|
if not package or not registry:
|
|
continue
|
|
|
|
curation_bar = COMPONENT_CURATION_BARS.get(component, FLAG_THRESHOLD)
|
|
|
|
cache_key = (registry, package)
|
|
if cache_key not in package_cache:
|
|
try:
|
|
if registry == "npm":
|
|
package_cache[cache_key] = rid.fetch_npm_downloads(package)
|
|
else:
|
|
package_cache[cache_key] = rid.fetch_pypi_downloads(package)
|
|
print(f"{registry}:{package} -> {package_cache[cache_key]}")
|
|
time.sleep(0.15)
|
|
except (requests.RequestException, ValueError, KeyError) as exc:
|
|
print(
|
|
f"warn: failed to fetch {registry} downloads for {package}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
downloads = package_cache[cache_key]
|
|
if downloads < flag_threshold:
|
|
continue
|
|
candidates.append(
|
|
Candidate(
|
|
language=language,
|
|
component=component,
|
|
name=name.strip(),
|
|
package=package,
|
|
registry=registry,
|
|
docs_url=docs_url.strip(),
|
|
downloads=downloads,
|
|
curation_bar=curation_bar,
|
|
)
|
|
)
|
|
|
|
candidates.sort(key=lambda c: (-c.downloads, c.name.lower()))
|
|
return candidates
|
|
|
|
|
|
def _linear_headers(api_key: str) -> dict[str, str]:
|
|
return {
|
|
"Authorization": api_key,
|
|
"Content-Type": "application/json",
|
|
**_HTTP_HEADERS,
|
|
}
|
|
|
|
|
|
def _linear_graphql(api_key: str, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
response = requests.post(
|
|
_LINEAR_URL,
|
|
headers=_linear_headers(api_key),
|
|
json={"query": query, "variables": variables},
|
|
timeout=_REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if payload.get("errors"):
|
|
raise RuntimeError(f"Linear GraphQL errors: {payload['errors']}")
|
|
data = payload.get("data")
|
|
if not isinstance(data, dict):
|
|
raise RuntimeError(f"Unexpected Linear response: {payload}")
|
|
return data
|
|
|
|
|
|
def resolve_team_id(api_key: str, team_key: str) -> str:
|
|
data = _linear_graphql(
|
|
api_key,
|
|
"""
|
|
query TeamByKey($key: String!) {
|
|
teams(filter: { key: { eq: $key } }, first: 1) {
|
|
nodes { id key name }
|
|
}
|
|
}
|
|
""",
|
|
{"key": team_key},
|
|
)
|
|
nodes = data.get("teams", {}).get("nodes") or []
|
|
if not nodes:
|
|
raise RuntimeError(f"No Linear team found for key {team_key!r}")
|
|
return str(nodes[0]["id"])
|
|
|
|
|
|
def find_open_issue(api_key: str, search_term: str) -> Optional[dict[str, Any]]:
|
|
data = _linear_graphql(
|
|
api_key,
|
|
"""
|
|
query SearchIssues($term: String!) {
|
|
searchIssues(term: $term, first: 25, includeArchived: false) {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
url
|
|
state { name type }
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
{"term": search_term},
|
|
)
|
|
nodes = data.get("searchIssues", {}).get("nodes") or []
|
|
for node in nodes:
|
|
title = str(node.get("title") or "")
|
|
state = node.get("state") or {}
|
|
state_type = str(state.get("type") or "")
|
|
if search_term not in title:
|
|
continue
|
|
# completed / canceled are done; anything else counts as open.
|
|
if state_type in {"completed", "canceled"}:
|
|
continue
|
|
return node
|
|
return None
|
|
|
|
|
|
def create_issue(api_key: str, team_id: str, candidate: Candidate) -> dict[str, Any]:
|
|
bar_note = (
|
|
f"This package is at or above the `{candidate.component}` curation bar "
|
|
f"({candidate.curation_bar:,}/mo)."
|
|
if candidate.meets_curation_bar
|
|
else (
|
|
f"This package crossed the {FLAG_THRESHOLD:,}/mo early-warning threshold. "
|
|
f"The `{candidate.component}` curation bar for hosting docs is "
|
|
f"{candidate.curation_bar:,}/mo."
|
|
)
|
|
)
|
|
description = f"""External integration crossed the {FLAG_THRESHOLD:,}/mo download flag threshold.
|
|
|
|
**Integration:** `{candidate.name}`
|
|
**Component:** `{candidate.component}` ({candidate.language})
|
|
**Package:** `{candidate.package}` ({candidate.registry})
|
|
**Monthly downloads:** {candidate.downloads:,}
|
|
**Component curation bar:** {candidate.curation_bar:,}/mo
|
|
**Current docs:** {candidate.docs_url}
|
|
|
|
{bar_note}
|
|
|
|
## Suggested action
|
|
|
|
Consider adding a hosted guide under `src/oss/{candidate.language}/integrations/{candidate.component}/` and removing this entry from `scripts/data/integration_external_docs.yaml` when it meets the component curation bar (or an explicit featured allowlist).
|
|
|
|
Filed by `scripts/flag_hosted_docs_candidates.py` during the weekly package-downloads workflow.
|
|
"""
|
|
data = _linear_graphql(
|
|
api_key,
|
|
"""
|
|
mutation CreateIssue($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
success
|
|
issue { id identifier title url }
|
|
}
|
|
}
|
|
""",
|
|
{
|
|
"input": {
|
|
"teamId": team_id,
|
|
"title": candidate.issue_title,
|
|
"description": description,
|
|
}
|
|
},
|
|
)
|
|
result = data.get("issueCreate") or {}
|
|
if not result.get("success") or not result.get("issue"):
|
|
raise RuntimeError(f"Failed to create Linear issue: {data}")
|
|
return result["issue"]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--threshold",
|
|
type=int,
|
|
default=None,
|
|
help=(
|
|
"Override the Linear flag threshold for all components "
|
|
f"(default: {FLAG_THRESHOLD:,}/mo)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--create",
|
|
action="store_true",
|
|
help="Create Linear issues for candidates (default: dry-run print only)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
candidates = collect_candidates(args.threshold)
|
|
if not candidates:
|
|
threshold = FLAG_THRESHOLD if args.threshold is None else args.threshold
|
|
print(f"No external integrations at or above {threshold:,} downloads/month.")
|
|
return 0
|
|
|
|
print(f"Found {len(candidates)} candidate(s) (>= {FLAG_THRESHOLD if args.threshold is None else args.threshold:,}/mo):")
|
|
for candidate in candidates:
|
|
bar_mark = "meets curation bar" if candidate.meets_curation_bar else (
|
|
f"below {candidate.component} bar {candidate.curation_bar:,}"
|
|
)
|
|
print(
|
|
f" - {candidate.language}/{candidate.component} "
|
|
f"{candidate.name}: {candidate.downloads:,} ({bar_mark}) "
|
|
f"({candidate.docs_url})"
|
|
)
|
|
|
|
if not args.create:
|
|
print("Dry-run only. Re-run with --create to file Linear issues.")
|
|
return 0
|
|
|
|
api_key = os.environ.get("LINEAR_API_KEY", "").strip()
|
|
team_key = os.environ.get("LINEAR_TEAM_KEY", "").strip()
|
|
if not api_key or not team_key:
|
|
print(
|
|
"error: LINEAR_API_KEY and LINEAR_TEAM_KEY are required with --create",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
team_id = resolve_team_id(api_key, team_key)
|
|
created = 0
|
|
skipped = 0
|
|
for candidate in candidates:
|
|
existing = find_open_issue(api_key, candidate.search_term)
|
|
if existing:
|
|
print(
|
|
f"skip: open issue already exists for {candidate.name}: "
|
|
f"{existing.get('identifier')} {existing.get('url')}"
|
|
)
|
|
skipped += 1
|
|
continue
|
|
issue = create_issue(api_key, team_id, candidate)
|
|
print(
|
|
f"created: {issue.get('identifier')} {issue.get('url')} "
|
|
f"({candidate.name})"
|
|
)
|
|
created += 1
|
|
time.sleep(0.2)
|
|
|
|
print(f"Done. created={created} skipped={skipped}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|