chore: import upstream snapshot with attribution
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# data-designer-github-repo-seed
|
||||
|
||||
A Data Designer seed-reader plugin for **Unsloth Studio** that scrapes real
|
||||
GitHub data (issues, pull requests, commits) from one or more repositories
|
||||
and hands it to the recipe pipeline as a seed dataset.
|
||||
|
||||
Designed to ship with Studio as a default seed source so any user with a
|
||||
GitHub token can build training datasets straight from live repos.
|
||||
|
||||
## What it does
|
||||
|
||||
Given a list of `owner/name` repos, a GitHub token, and a per-resource
|
||||
`limit`, the plugin uses GitHub's GraphQL API to fetch issues, pull
|
||||
requests, and/or commits, with labels, state, authors, and the first N
|
||||
comments of each item, and materialises a single JSONL with uniform
|
||||
columns so the rest of the recipe (LLM text / LLM structured / processors)
|
||||
can treat it like any other seed table.
|
||||
|
||||
| Column | Description |
|
||||
|---------------|------------------------------------------------|
|
||||
| `item_type` | `issue` / `pull` / `commit` |
|
||||
| `repo` | `owner/name` |
|
||||
| `number` | Issue/PR number, or commit SHA |
|
||||
| `title` | Title (or commit message headline) |
|
||||
| `body` | Issue/PR body (or full commit message) |
|
||||
| `state` | `OPEN` / `CLOSED` / `MERGED` (empty for commit)|
|
||||
| `author` | GitHub login of the author |
|
||||
| `created_at` | ISO8601 |
|
||||
| `closed_at` | ISO8601 (empty for commits) |
|
||||
| `url` | Permalink |
|
||||
| `labels` | List of label names |
|
||||
| `comments` | First N comments concatenated |
|
||||
|
||||
## Usage in a recipe
|
||||
|
||||
```json
|
||||
{
|
||||
"seed_config": {
|
||||
"source": {
|
||||
"seed_type": "github_repo",
|
||||
"repos": ["unslothai/unsloth", "unslothai/unsloth-zoo"],
|
||||
"token": "",
|
||||
"item_types": ["issues", "pulls"],
|
||||
"limit": 100,
|
||||
"include_comments": true,
|
||||
"max_comments_per_item": 30
|
||||
},
|
||||
"sampling_strategy": "shuffle",
|
||||
"selection_strategy": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `token` empty to fall back to the server's `GH_TOKEN` / `GITHUB_TOKEN`
|
||||
environment variable, useful when the recipe is published and shouldn't
|
||||
carry a secret.
|
||||
|
||||
## Auth
|
||||
|
||||
A GitHub personal access token with `public_repo` scope is enough for public
|
||||
repositories; `repo` scope is required for private ones. GraphQL requests
|
||||
are rate-limit aware: the client inspects `x-ratelimit-*` headers and
|
||||
sleeps until reset when the budget drops below a safety threshold.
|
||||
|
||||
## Install
|
||||
|
||||
Shipped as a default Studio plugin. For development:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Registered automatically via the `data_designer.plugins` entry point.
|
||||
@@ -0,0 +1,25 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-designer-github-repo-seed"
|
||||
version = "0.1.0"
|
||||
description = "Unsloth Studio seed plugin that scrapes GitHub issues, PRs, and commits."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"data-designer-engine>=0.5.4,<0.6",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[project.entry-points."data_designer.plugins"]
|
||||
github_repo_seed = "data_designer_github_repo_seed.plugin:github_repo_seed_plugin"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
# Intentionally empty. Data-designer loads submodules lazily via qualified names
|
||||
# in plugin.py, so importing this package must not touch data_designer.engine.*
|
||||
# during Studio bootstrap (circular import).
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from data_designer.config.seed_source import SeedSource
|
||||
|
||||
|
||||
class GitHubRepoSeedSource(SeedSource):
|
||||
seed_type: Literal["github_repo"] = "github_repo"
|
||||
|
||||
repos: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "List of GitHub repositories to scrape, each in `owner/name` form.",
|
||||
)
|
||||
token: str = Field(
|
||||
default = "",
|
||||
description = "Personal access token. Leave blank to read GH_TOKEN / GITHUB_TOKEN from env at run time.",
|
||||
)
|
||||
item_types: list[Literal["issues", "pulls", "commits"]] = Field(
|
||||
default = ["issues", "pulls"],
|
||||
description = "Which GitHub item types to fetch per repo.",
|
||||
)
|
||||
limit: int = Field(
|
||||
default = 100,
|
||||
ge = 1,
|
||||
le = 5000,
|
||||
description = "Maximum items per repo per item type (e.g. limit=100 + ['issues','pulls'] => up to 200 items per repo).",
|
||||
)
|
||||
include_comments: bool = Field(
|
||||
default = True,
|
||||
description = "Fetch the first N comments of each issue/PR and include them in the `comments` column.",
|
||||
)
|
||||
max_comments_per_item: int = Field(default = 30, ge = 0, le = 200)
|
||||
|
||||
@field_validator("repos")
|
||||
@classmethod
|
||||
def _validate_repos(cls, v: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for r in v or []:
|
||||
r = r.strip()
|
||||
if not r:
|
||||
continue
|
||||
if r.count("/") != 1 or not all(r.split("/")):
|
||||
raise ValueError(f"Each repo must be `owner/name`; got {r!r}")
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
@field_validator("item_types")
|
||||
@classmethod
|
||||
def _validate_item_types(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("item_types must not be empty")
|
||||
return list(dict.fromkeys(v))
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _ensure_repos(self) -> "GitHubRepoSeedSource":
|
||||
if not self.repos:
|
||||
raise ValueError("At least one repo is required")
|
||||
return self
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import data_designer.lazy_heavy_imports as lazy
|
||||
from data_designer.engine.resources.seed_reader import SeedReader
|
||||
|
||||
from .config import GitHubRepoSeedSource
|
||||
from .scraper import ScrapeConfig, materialize_to_jsonl
|
||||
|
||||
|
||||
# In-process cache: config signature -> JSONL path. A recipe job reads the seed
|
||||
# multiple times (validation, preview, sampling); memoize to avoid re-scraping
|
||||
# GitHub on each call. Key uses a short SHA-256 of the token (never the raw value)
|
||||
# so token never hits memory twice and rotation invalidates cleanly.
|
||||
_SCRAPE_CACHE: dict[tuple, str] = {}
|
||||
_SCRAPE_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _scrape_cache_key(cfg: ScrapeConfig) -> tuple:
|
||||
token_digest = hashlib.sha256(
|
||||
(cfg.token or "").encode("utf-8"),
|
||||
).hexdigest()[:16]
|
||||
return (
|
||||
tuple(cfg.repos),
|
||||
tuple(cfg.item_types),
|
||||
cfg.limit,
|
||||
bool(cfg.include_comments),
|
||||
cfg.max_comments_per_item,
|
||||
token_digest,
|
||||
)
|
||||
|
||||
|
||||
def _lookup_cached_scrape(key: tuple) -> Optional[str]:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
path = _SCRAPE_CACHE.get(key)
|
||||
if path and Path(path).exists():
|
||||
return path
|
||||
# Stale entry (tmp cleanup/restart); drop it so the caller re-materializes.
|
||||
if path:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
_SCRAPE_CACHE.pop(key, None)
|
||||
return None
|
||||
|
||||
|
||||
def _store_cached_scrape(key: tuple, path: str) -> None:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
_SCRAPE_CACHE[key] = path
|
||||
|
||||
|
||||
class GitHubRepoSeedReader(SeedReader[GitHubRepoSeedSource]):
|
||||
def create_duckdb_connection(self):
|
||||
return lazy.duckdb.connect()
|
||||
|
||||
def get_dataset_uri(self) -> str:
|
||||
out_dir = Path(tempfile.gettempdir()) / "studio-github-repo-seed"
|
||||
cfg = ScrapeConfig(
|
||||
repos = list(self.source.repos),
|
||||
token = self.source.token,
|
||||
item_types = list(self.source.item_types),
|
||||
limit = self.source.limit,
|
||||
include_comments = self.source.include_comments,
|
||||
max_comments_per_item = self.source.max_comments_per_item,
|
||||
)
|
||||
cache_key = _scrape_cache_key(cfg)
|
||||
cached_path = _lookup_cached_scrape(cache_key)
|
||||
if cached_path is not None:
|
||||
return cached_path
|
||||
path = materialize_to_jsonl(cfg, out_dir)
|
||||
_store_cached_scrape(cache_key, str(path))
|
||||
return str(path)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from data_designer.plugins.plugin import Plugin, PluginType
|
||||
|
||||
github_repo_seed_plugin = Plugin(
|
||||
impl_qualified_name = "data_designer_github_repo_seed.impl.GitHubRepoSeedReader",
|
||||
config_qualified_name = "data_designer_github_repo_seed.config.GitHubRepoSeedSource",
|
||||
plugin_type = PluginType.SEED_READER,
|
||||
)
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Multi-repo GitHub scraper for the Studio seed plugin.
|
||||
|
||||
Drives the GraphQL scraper in `scraper_impl/` per repo, capped via trial_limits
|
||||
to stop at `limit` items per resource. Then reads the per-resource JSONL shards
|
||||
and flattens them into one unified JSONL with stable columns (`item_type`,
|
||||
`repo`, `number`, `title`, `body`, ...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Defer scraper_impl imports until scrape() has a resolved token.
|
||||
_IMPL_DIR = Path(__file__).parent / "scraper_impl"
|
||||
|
||||
|
||||
def _ensure_impl_on_path() -> None:
|
||||
if str(_IMPL_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_IMPL_DIR))
|
||||
|
||||
|
||||
def _load_impl():
|
||||
_ensure_impl_on_path()
|
||||
import importlib
|
||||
|
||||
gh_client = importlib.import_module("gh_client") # type: ignore
|
||||
scraper_mod = importlib.import_module("scraper") # type: ignore
|
||||
return gh_client.GitHubClient, scraper_mod.RepoScraper
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapeConfig:
|
||||
repos: list[str]
|
||||
token: str
|
||||
item_types: list[str]
|
||||
limit: int
|
||||
include_comments: bool
|
||||
max_comments_per_item: int
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class ResolvedToken:
|
||||
value: str
|
||||
source: str
|
||||
|
||||
|
||||
def _resolve_token(token: str) -> ResolvedToken:
|
||||
if token:
|
||||
return ResolvedToken(
|
||||
value = token,
|
||||
source = "explicit token argument (recipe-level field)",
|
||||
)
|
||||
if os.environ.get("GH_TOKEN"):
|
||||
return ResolvedToken(
|
||||
value = os.environ["GH_TOKEN"],
|
||||
source = "GH_TOKEN environment variable",
|
||||
)
|
||||
if os.environ.get("GITHUB_TOKEN"):
|
||||
return ResolvedToken(
|
||||
value = os.environ["GITHUB_TOKEN"],
|
||||
source = "GITHUB_TOKEN environment variable",
|
||||
)
|
||||
raise ValueError(
|
||||
"GitHub token is required. Set it in the recipe config or the GH_TOKEN / GITHUB_TOKEN env var."
|
||||
)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, max_rows: int | None = None):
|
||||
if not path.exists():
|
||||
return
|
||||
with path.open(encoding = "utf-8") as f:
|
||||
for i, line in enumerate(f):
|
||||
if not line.strip():
|
||||
continue
|
||||
if max_rows is not None and i >= max_rows:
|
||||
return
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def _flatten_issue_row(r: dict, repo: str, include_comments: bool, max_c: int) -> dict:
|
||||
labels = [l.get("name") for l in (r.get("labels", {}) or {}).get("nodes", []) if l.get("name")]
|
||||
comments_nodes = (r.get("comments") or {}).get("nodes") or []
|
||||
comments_text = ""
|
||||
if include_comments and comments_nodes:
|
||||
kept = comments_nodes[:max_c]
|
||||
comments_text = "\n\n".join(
|
||||
f"[{(c.get('author') or {}).get('login', '?')}]: {c.get('body') or ''}" for c in kept
|
||||
)
|
||||
return {
|
||||
"item_type": "issue",
|
||||
"repo": repo,
|
||||
"number": r.get("number"),
|
||||
"title": r.get("title") or "",
|
||||
"body": r.get("body") or "",
|
||||
"state": r.get("state") or "",
|
||||
"author": (r.get("author") or {}).get("login", ""),
|
||||
"created_at": r.get("createdAt") or "",
|
||||
"closed_at": r.get("closedAt") or "",
|
||||
"url": r.get("url") or r.get("permalink") or "",
|
||||
"labels": labels,
|
||||
"comments": comments_text,
|
||||
}
|
||||
|
||||
|
||||
def _flatten_pr_row(r: dict, repo: str, include_comments: bool, max_c: int) -> dict:
|
||||
labels = [l.get("name") for l in (r.get("labels", {}) or {}).get("nodes", []) if l.get("name")]
|
||||
comments_nodes = (r.get("comments") or {}).get("nodes") or []
|
||||
comments_text = ""
|
||||
if include_comments and comments_nodes:
|
||||
kept = comments_nodes[:max_c]
|
||||
comments_text = "\n\n".join(
|
||||
f"[{(c.get('author') or {}).get('login', '?')}]: {c.get('body') or ''}" for c in kept
|
||||
)
|
||||
return {
|
||||
"item_type": "pull",
|
||||
"repo": repo,
|
||||
"number": r.get("number"),
|
||||
"title": r.get("title") or "",
|
||||
"body": r.get("body") or "",
|
||||
"state": r.get("state") or "",
|
||||
"author": (r.get("author") or {}).get("login", ""),
|
||||
"created_at": r.get("createdAt") or "",
|
||||
"closed_at": r.get("closedAt") or "",
|
||||
"url": r.get("url") or r.get("permalink") or "",
|
||||
"labels": labels,
|
||||
"comments": comments_text,
|
||||
}
|
||||
|
||||
|
||||
def _flatten_commit_row(r: dict, repo: str) -> dict:
|
||||
msg = r.get("messageHeadline") or r.get("message") or ""
|
||||
body = r.get("messageBody") or r.get("message") or msg
|
||||
author = r.get("author") or {}
|
||||
return {
|
||||
"item_type": "commit",
|
||||
"repo": repo,
|
||||
"number": r.get("oid") or r.get("sha") or "",
|
||||
"title": msg,
|
||||
"body": body,
|
||||
"state": "",
|
||||
"author": (author.get("user") or {}).get("login") or author.get("name", ""),
|
||||
"created_at": (author.get("date") or r.get("committedDate") or ""),
|
||||
"closed_at": "",
|
||||
"url": r.get("url") or "",
|
||||
"labels": [],
|
||||
"comments": "",
|
||||
}
|
||||
|
||||
|
||||
def scrape(cfg: ScrapeConfig, base_dir: Path):
|
||||
token = _resolve_token(cfg.token)
|
||||
GitHubClient, RepoScraper = _load_impl()
|
||||
client = GitHubClient(token = token.value, token_source = token.source)
|
||||
base_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Per-resource limits; limit <= 0 means "all" (large cap).
|
||||
effective_limit = cfg.limit if cfg.limit and cfg.limit > 0 else 1_000_000
|
||||
trial_limits: dict[str, int] = {}
|
||||
if "issues" in cfg.item_types:
|
||||
trial_limits["issues"] = effective_limit
|
||||
if "pulls" in cfg.item_types:
|
||||
trial_limits["pull_requests"] = effective_limit
|
||||
if "commits" in cfg.item_types:
|
||||
trial_limits["commits"] = effective_limit
|
||||
|
||||
all_rows: list[dict] = []
|
||||
for repo in cfg.repos:
|
||||
owner, name = repo.split("/", 1)
|
||||
scraper = RepoScraper(
|
||||
owner = owner,
|
||||
name = name,
|
||||
base_dir = base_dir,
|
||||
client = client,
|
||||
trial_limits = trial_limits,
|
||||
light = True,
|
||||
)
|
||||
try:
|
||||
repo_meta = scraper.scrape_repo_meta()
|
||||
if "issues" in cfg.item_types:
|
||||
scraper.scrape_issues()
|
||||
if "pulls" in cfg.item_types:
|
||||
scraper.scrape_prs()
|
||||
if "commits" in cfg.item_types:
|
||||
default_ref = repo_meta.get("defaultBranchRef") or {}
|
||||
default_branch = default_ref.get("name") if isinstance(default_ref, dict) else None
|
||||
branch = f"refs/heads/{default_branch}" if default_branch else "refs/heads/main"
|
||||
scraper.scrape_commits(branch = branch)
|
||||
finally:
|
||||
scraper.close()
|
||||
|
||||
read_cap = cfg.limit if cfg.limit and cfg.limit > 0 else None
|
||||
repo_dir = base_dir / f"{owner}__{name}"
|
||||
if "issues" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "issues.jsonl", read_cap):
|
||||
all_rows.append(
|
||||
_flatten_issue_row(row, repo, cfg.include_comments, cfg.max_comments_per_item)
|
||||
)
|
||||
if "pulls" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "pull_requests.jsonl", read_cap):
|
||||
all_rows.append(
|
||||
_flatten_pr_row(row, repo, cfg.include_comments, cfg.max_comments_per_item)
|
||||
)
|
||||
if "commits" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "commits.jsonl", read_cap):
|
||||
all_rows.append(_flatten_commit_row(row, repo))
|
||||
|
||||
return all_rows
|
||||
|
||||
|
||||
def materialize_to_jsonl(cfg: ScrapeConfig, out_dir: Path) -> Path:
|
||||
out_dir.mkdir(parents = True, exist_ok = True)
|
||||
tag = "-".join(r.replace("/", "__") for r in cfg.repos)[:120]
|
||||
kinds = "-".join(cfg.item_types)
|
||||
run_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}"
|
||||
fname = f"github_{tag}__{kinds}__{cfg.limit}_{run_id}.jsonl"
|
||||
out = out_dir / fname
|
||||
rows = scrape(cfg, out_dir / "raw-runs" / run_id)
|
||||
with out.open("w", encoding = "utf-8") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii = False) + "\n")
|
||||
return out
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""GitHub API client with rate-limit awareness, retry, and dual REST/GraphQL support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger("gh_client")
|
||||
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
REST_BASE = "https://api.github.com"
|
||||
|
||||
BASE_HEADERS = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "github-data-gatherer/1.0",
|
||||
}
|
||||
|
||||
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GitHubAuthError(RuntimeError):
|
||||
"""Raised when GitHub returns 401/403 due to invalid or insufficient credentials."""
|
||||
|
||||
|
||||
def _retry_after_seconds(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
retry_at = parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
return None
|
||||
if retry_at.tzinfo is None:
|
||||
retry_at = retry_at.replace(tzinfo = timezone.utc)
|
||||
return max(0, int(retry_at.timestamp() - time.time()))
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
def __init__(
|
||||
self,
|
||||
min_remaining_graphql: int = 100,
|
||||
min_remaining_rest: int = 100,
|
||||
token: str | None = None,
|
||||
token_source: str | None = None,
|
||||
):
|
||||
if token:
|
||||
self._token_source = token_source or "explicit token argument (recipe-level field)"
|
||||
elif os.environ.get("GH_TOKEN"):
|
||||
self._token_source = "GH_TOKEN environment variable"
|
||||
token = os.environ["GH_TOKEN"]
|
||||
elif os.environ.get("GITHUB_TOKEN"):
|
||||
self._token_source = "GITHUB_TOKEN environment variable"
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
else:
|
||||
raise RuntimeError("GH_TOKEN or GITHUB_TOKEN not set in environment")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({**BASE_HEADERS, "Authorization": f"Bearer {token}"})
|
||||
self.min_remaining_graphql = min_remaining_graphql
|
||||
self.min_remaining_rest = min_remaining_rest
|
||||
self.graphql_remaining: Optional[int] = None
|
||||
self.graphql_reset: Optional[int] = None
|
||||
self.rest_remaining: Optional[int] = None
|
||||
self.rest_reset: Optional[int] = None
|
||||
self.calls_graphql = 0
|
||||
self.calls_rest = 0
|
||||
self.retry_count = 0
|
||||
|
||||
def _sleep_until(
|
||||
self,
|
||||
reset_ts: int,
|
||||
buffer_s: int = 10,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
wait = max(0, reset_ts - now) + buffer_s
|
||||
log.warning("Rate limit hit. Sleeping %ds until reset.", wait)
|
||||
time.sleep(wait)
|
||||
|
||||
def _is_rate_limit_response(self, r: "requests.Response") -> bool:
|
||||
if r.headers.get("Retry-After"):
|
||||
return True
|
||||
if r.headers.get("X-RateLimit-Remaining") == "0":
|
||||
return True
|
||||
body = (r.text or "").lower()
|
||||
return any(
|
||||
marker in body
|
||||
for marker in (
|
||||
"api rate limit exceeded",
|
||||
"rate limit exceeded",
|
||||
"secondary rate limit",
|
||||
"secondary limit",
|
||||
"abuse detection mechanism",
|
||||
"abuse detection",
|
||||
)
|
||||
)
|
||||
|
||||
def _is_auth_failure(self, r: "requests.Response") -> bool:
|
||||
"""Tell auth failures apart from rate limiting on 401/403.
|
||||
|
||||
401 is always auth; 403 is auth unless it carries a rate-limit signal
|
||||
(Retry-After, X-RateLimit-Remaining: 0, or abuse/secondary text).
|
||||
"""
|
||||
if r.status_code == 401:
|
||||
return True
|
||||
if r.status_code == 403:
|
||||
return not self._is_rate_limit_response(r)
|
||||
return False
|
||||
|
||||
def _raise_auth_error(self, r: "requests.Response", endpoint: str) -> None:
|
||||
snippet = (r.text or "").strip()[:200]
|
||||
request_id = r.headers.get("X-GitHub-Request-Id")
|
||||
request_id_message = f" Request ID: {request_id}." if request_id else ""
|
||||
raise GitHubAuthError(
|
||||
f"GitHub {endpoint} returned {r.status_code} {r.reason}. "
|
||||
f"Token source: {self._token_source}. "
|
||||
f"The token is invalid, expired, or missing required scopes — "
|
||||
f"retrying will not recover.{request_id_message} Response: {snippet}"
|
||||
)
|
||||
|
||||
def _check_rate_and_wait(self, kind: str) -> None:
|
||||
if kind == "graphql":
|
||||
remaining = self.graphql_remaining
|
||||
reset = self.graphql_reset
|
||||
min_remaining = self.min_remaining_graphql
|
||||
else:
|
||||
remaining = self.rest_remaining
|
||||
reset = self.rest_reset
|
||||
min_remaining = self.min_remaining_rest
|
||||
if remaining is not None and remaining < min_remaining:
|
||||
if reset:
|
||||
self._sleep_until(reset)
|
||||
# Reset remaining so we don't spin
|
||||
if kind == "graphql":
|
||||
self.graphql_remaining = None
|
||||
else:
|
||||
self.rest_remaining = None
|
||||
|
||||
def graphql(
|
||||
self,
|
||||
query: str,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
self._check_rate_and_wait("graphql")
|
||||
backoff = 2
|
||||
last_err = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
r = self.session.post(
|
||||
GRAPHQL_URL,
|
||||
json = {"query": query, "variables": variables or {}},
|
||||
timeout = 120,
|
||||
)
|
||||
self.calls_graphql += 1
|
||||
rem = r.headers.get("X-RateLimit-Remaining")
|
||||
rst = r.headers.get("X-RateLimit-Reset")
|
||||
if rem is not None:
|
||||
try:
|
||||
self.graphql_remaining = int(rem)
|
||||
except ValueError:
|
||||
pass
|
||||
if rst is not None:
|
||||
try:
|
||||
self.graphql_reset = int(rst)
|
||||
except ValueError:
|
||||
pass
|
||||
if r.status_code in (502, 503, 504):
|
||||
log.warning("GraphQL %s transient, retrying", r.status_code)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if self._is_auth_failure(r):
|
||||
self._raise_auth_error(r, "GraphQL")
|
||||
if r.status_code == 403 or r.status_code == 429:
|
||||
# Secondary/abuse rate limit
|
||||
retry_after = _retry_after_seconds(r.headers.get("Retry-After"))
|
||||
if retry_after is not None:
|
||||
log.warning("Secondary rate limit. Sleep %ds.", retry_after)
|
||||
time.sleep(retry_after + 2)
|
||||
continue
|
||||
if self.graphql_reset:
|
||||
self._sleep_until(self.graphql_reset)
|
||||
continue
|
||||
time.sleep(60)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "errors" in data and data["errors"]:
|
||||
# Allow partial data; retry on RATE_LIMITED
|
||||
errs = data["errors"]
|
||||
for e in errs:
|
||||
if e.get("type") == "RATE_LIMITED":
|
||||
self._sleep_until((self.graphql_reset or int(time.time()) + 60))
|
||||
break
|
||||
else:
|
||||
# No rate-limit error: log and return partial
|
||||
log.warning("GraphQL errors: %s", json.dumps(errs)[:400])
|
||||
return data
|
||||
continue
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
log.warning("GraphQL network error: %s. Retry.", e)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
raise RuntimeError(f"GraphQL failed after {max_retries} retries: {last_err}")
|
||||
|
||||
def rest(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 6,
|
||||
) -> requests.Response:
|
||||
self._check_rate_and_wait("rest")
|
||||
if path.startswith("http"):
|
||||
url = path
|
||||
else:
|
||||
url = REST_BASE + path
|
||||
backoff = 2
|
||||
last_err = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
r = self.session.request(method, url, params = params, json = json_body, timeout = 120)
|
||||
self.calls_rest += 1
|
||||
rem = r.headers.get("X-RateLimit-Remaining")
|
||||
rst = r.headers.get("X-RateLimit-Reset")
|
||||
if rem is not None:
|
||||
try:
|
||||
self.rest_remaining = int(rem)
|
||||
except ValueError:
|
||||
pass
|
||||
if rst is not None:
|
||||
try:
|
||||
self.rest_reset = int(rst)
|
||||
except ValueError:
|
||||
pass
|
||||
if r.status_code in (502, 503, 504):
|
||||
log.warning("REST %s transient, retrying", r.status_code)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if self._is_auth_failure(r):
|
||||
self._raise_auth_error(r, "REST")
|
||||
if r.status_code in (403, 429):
|
||||
retry_after = _retry_after_seconds(r.headers.get("Retry-After"))
|
||||
if retry_after is not None:
|
||||
log.warning("Secondary rate limit on REST. Sleep %ds.", retry_after)
|
||||
time.sleep(retry_after + 2)
|
||||
continue
|
||||
# Primary rate limit
|
||||
if self.rest_remaining == 0 and self.rest_reset:
|
||||
self._sleep_until(self.rest_reset)
|
||||
continue
|
||||
log.warning("REST 403/429, sleep 60")
|
||||
time.sleep(60)
|
||||
continue
|
||||
return r
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
log.warning("REST network error: %s. Retry.", e)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
raise RuntimeError(f"REST failed after {max_retries} retries: {last_err}")
|
||||
|
||||
def rest_paginate(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
per_page: int = 100,
|
||||
) -> Iterator[dict]:
|
||||
params = dict(params or {})
|
||||
params.setdefault("per_page", per_page)
|
||||
url = path
|
||||
while True:
|
||||
r = self.rest("GET", url, params = params if url == path else None)
|
||||
if r.status_code != 200:
|
||||
log.error("REST paginate got %s at %s: %s", r.status_code, url, r.text[:200])
|
||||
return
|
||||
items = r.json()
|
||||
if isinstance(items, dict):
|
||||
# Some endpoints wrap the list in an "items" field
|
||||
items = items.get("items", [])
|
||||
for it in items:
|
||||
yield it
|
||||
link = r.headers.get("Link", "")
|
||||
nxt = None
|
||||
for part in link.split(","):
|
||||
if 'rel="next"' in part:
|
||||
nxt = part.split(";")[0].strip().strip("<>")
|
||||
break
|
||||
if not nxt:
|
||||
return
|
||||
url = nxt
|
||||
params = None
|
||||
|
||||
def rate_snapshot(self) -> Dict[str, Any]:
|
||||
r = self.rest("GET", "/rate_limit")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
return {}
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""GraphQL queries for GitHub data scraping.
|
||||
|
||||
GitHub's GraphQL rejects queries with unused fragments, so each query includes
|
||||
only the fragments it references.
|
||||
"""
|
||||
|
||||
# ---- Fragments (raw strings, composed per query) ----
|
||||
F_ACTOR = """
|
||||
fragment ActorFields on Actor {
|
||||
__typename
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
... on User { id databaseId name }
|
||||
... on Bot { id databaseId }
|
||||
... on Organization { id databaseId name }
|
||||
}
|
||||
"""
|
||||
|
||||
F_LABEL = """
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
"""
|
||||
|
||||
F_TIMELINE = """
|
||||
fragment TimelineItem on IssueTimelineItems {
|
||||
__typename
|
||||
... on Node { id }
|
||||
... on AddedToProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on AssignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on ClosedEvent { createdAt actor { ...ActorFields } stateReason closer { __typename ... on Commit { oid url } ... on PullRequest { number url } } }
|
||||
... on CommentDeletedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ConnectedEvent { createdAt actor { ...ActorFields } source { __typename ... on Issue { number url repository { nameWithOwner } } ... on PullRequest { number url repository { nameWithOwner } } } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on ConvertedNoteToIssueEvent { createdAt actor { ...ActorFields } }
|
||||
... on CrossReferencedEvent { createdAt actor { ...ActorFields } isCrossRepository willCloseTarget source { __typename ... on Issue { number url repository { nameWithOwner } title } ... on PullRequest { number url repository { nameWithOwner } title } } }
|
||||
... on DemilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on DisconnectedEvent { createdAt actor { ...ActorFields } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } source { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on IssueComment { id databaseId createdAt updatedAt author { ...ActorFields } body url reactionGroups { content reactors { totalCount } } }
|
||||
... on LabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on LockedEvent { createdAt actor { ...ActorFields } lockReason }
|
||||
... on MarkedAsDuplicateEvent { createdAt actor { ...ActorFields } canonical { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on MentionedEvent { createdAt actor { ...ActorFields } }
|
||||
... on MilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on MovedColumnsInProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on PinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReferencedEvent { createdAt actor { ...ActorFields } commit { oid url } commitRepository { nameWithOwner } }
|
||||
... on RemovedFromProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on RenamedTitleEvent { createdAt actor { ...ActorFields } previousTitle currentTitle }
|
||||
... on ReopenedEvent { createdAt actor { ...ActorFields } }
|
||||
... on SubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on TransferredEvent { createdAt actor { ...ActorFields } fromRepository { nameWithOwner } }
|
||||
... on UnassignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on UnlabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on UnlockedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnmarkedAsDuplicateEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnpinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnsubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UserBlockedEvent { createdAt actor { ...ActorFields } blockDuration }
|
||||
}
|
||||
"""
|
||||
|
||||
F_PR_TIMELINE = """
|
||||
fragment PRTimelineItem on PullRequestTimelineItems {
|
||||
__typename
|
||||
... on Node { id }
|
||||
... on AssignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on AutoMergeDisabledEvent { createdAt actor { ...ActorFields } reason }
|
||||
... on AutoMergeEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutoRebaseEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutoSquashEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutomaticBaseChangeFailedEvent { createdAt actor { ...ActorFields } oldBase newBase }
|
||||
... on AutomaticBaseChangeSucceededEvent { createdAt actor { ...ActorFields } oldBase newBase }
|
||||
... on BaseRefChangedEvent { createdAt actor { ...ActorFields } previousRefName currentRefName }
|
||||
... on BaseRefDeletedEvent { createdAt actor { ...ActorFields } baseRefName }
|
||||
... on BaseRefForcePushedEvent { createdAt actor { ...ActorFields } beforeCommit { oid } afterCommit { oid } ref { name } }
|
||||
... on ClosedEvent { createdAt actor { ...ActorFields } stateReason }
|
||||
... on CommentDeletedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ConnectedEvent { createdAt actor { ...ActorFields } source { __typename ... on Issue { number url } ... on PullRequest { number url } } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on ConvertToDraftEvent { createdAt actor { ...ActorFields } }
|
||||
... on CrossReferencedEvent { createdAt actor { ...ActorFields } isCrossRepository willCloseTarget source { __typename ... on Issue { number url repository { nameWithOwner } title } ... on PullRequest { number url repository { nameWithOwner } title } } }
|
||||
... on DemilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on DeployedEvent { createdAt actor { ...ActorFields } }
|
||||
... on DeploymentEnvironmentChangedEvent { createdAt actor { ...ActorFields } }
|
||||
... on DisconnectedEvent { createdAt actor { ...ActorFields } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } source { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on HeadRefDeletedEvent { createdAt actor { ...ActorFields } headRefName }
|
||||
... on HeadRefForcePushedEvent { createdAt actor { ...ActorFields } beforeCommit { oid } afterCommit { oid } ref { name } }
|
||||
... on HeadRefRestoredEvent { createdAt actor { ...ActorFields } }
|
||||
... on IssueComment { id databaseId createdAt updatedAt author { ...ActorFields } body url reactionGroups { content reactors { totalCount } } }
|
||||
... on LabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on LockedEvent { createdAt actor { ...ActorFields } lockReason }
|
||||
... on MarkedAsDuplicateEvent { createdAt actor { ...ActorFields } canonical { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on MentionedEvent { createdAt actor { ...ActorFields } }
|
||||
... on MergedEvent { createdAt actor { ...ActorFields } commit { oid url } mergeRefName }
|
||||
... on MilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on MovedColumnsInProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on PinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on PullRequestCommit { commit { oid url message author { user { login } date } committedDate } }
|
||||
... on PullRequestCommitCommentThread { commit { oid } }
|
||||
... on PullRequestReview { id databaseId createdAt submittedAt author { ...ActorFields } body state url reactionGroups { content reactors { totalCount } } }
|
||||
... on PullRequestReviewThread { id isResolved isOutdated path line diffSide }
|
||||
... on PullRequestRevisionMarker { createdAt lastSeenCommit { oid } }
|
||||
... on ReadyForReviewEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReferencedEvent { createdAt actor { ...ActorFields } commit { oid url } commitRepository { nameWithOwner } }
|
||||
... on RenamedTitleEvent { createdAt actor { ...ActorFields } previousTitle currentTitle }
|
||||
... on ReopenedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReviewDismissedEvent { createdAt actor { ...ActorFields } dismissalMessage previousReviewState }
|
||||
... on ReviewRequestRemovedEvent { createdAt actor { ...ActorFields } requestedReviewer { __typename ... on User { login } ... on Team { name } } }
|
||||
... on ReviewRequestedEvent { createdAt actor { ...ActorFields } requestedReviewer { __typename ... on User { login } ... on Team { name } } }
|
||||
... on SubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on TransferredEvent { createdAt actor { ...ActorFields } fromRepository { nameWithOwner } }
|
||||
... on UnassignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on UnlabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on UnlockedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnmarkedAsDuplicateEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnpinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnsubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UserBlockedEvent { createdAt actor { ...ActorFields } blockDuration }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _q(parts: list[str], body: str) -> str:
|
||||
return "\n".join(parts + [body])
|
||||
|
||||
|
||||
ISSUES_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL, F_TIMELINE],
|
||||
"""
|
||||
query IssuesPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state stateReason
|
||||
createdAt updatedAt closedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
assignees(first: 20) { nodes { login id } }
|
||||
milestone { title number state dueOn }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
comments(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
timelineItems(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...TimelineItem }
|
||||
}
|
||||
trackedInIssues(first: 20) { totalCount nodes { number url repository { nameWithOwner } } }
|
||||
trackedIssues(first: 20) { totalCount nodes { number url repository { nameWithOwner } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PRS_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL, F_PR_TIMELINE],
|
||||
"""
|
||||
query PRsPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state isDraft
|
||||
createdAt updatedAt closedAt mergedAt
|
||||
url
|
||||
headRefName headRefOid
|
||||
baseRefName baseRefOid
|
||||
additions deletions changedFiles
|
||||
mergeable merged mergeStateStatus
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
mergedBy { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
assignees(first: 20) { nodes { login id } }
|
||||
milestone { title number state dueOn }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
closingIssuesReferences(first: 20) { totalCount nodes { number url repository { nameWithOwner } title } }
|
||||
comments(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
reviewThreads(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id isResolved isOutdated path line diffSide
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body path diffHunk
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
position originalPosition line originalLine
|
||||
commit { oid }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId state createdAt submittedAt body url
|
||||
author { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
commits(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
message
|
||||
messageHeadline
|
||||
committedDate
|
||||
authoredDate
|
||||
author { name email user { login } date }
|
||||
committer { name email user { login } date }
|
||||
additions deletions changedFilesIfAvailable
|
||||
parents(first: 3) { nodes { oid } }
|
||||
}
|
||||
}
|
||||
}
|
||||
files(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
path additions deletions changeType
|
||||
}
|
||||
}
|
||||
timelineItems(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...PRTimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PRS_PAGE_QUERY_LIGHT = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query PRsPageLight($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state isDraft
|
||||
createdAt updatedAt closedAt mergedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
comments(first: 30) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUES_PAGE_QUERY_LIGHT = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query IssuesPageLight($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state
|
||||
createdAt updatedAt closedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
comments(first: 30) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUE_COMMENTS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query IssueComments($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issueOrPullRequest(number: $number) {
|
||||
__typename
|
||||
... on Issue {
|
||||
comments(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
... on PullRequest {
|
||||
comments(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUE_TIMELINE_QUERY = _q(
|
||||
[F_ACTOR, F_TIMELINE],
|
||||
"""
|
||||
query IssueTimeline($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $number) {
|
||||
timelineItems(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...TimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PR_TIMELINE_QUERY = _q(
|
||||
[F_ACTOR, F_PR_TIMELINE],
|
||||
"""
|
||||
query PRTimeline($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
timelineItems(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...PRTimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PR_COMMITS_QUERY = """
|
||||
query PRCommits($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
commits(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
commit {
|
||||
oid message messageHeadline committedDate authoredDate
|
||||
author { name email user { login } date }
|
||||
committer { name email user { login } date }
|
||||
additions deletions changedFilesIfAvailable
|
||||
parents(first: 3) { nodes { oid } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
PR_FILES_QUERY = """
|
||||
query PRFiles($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
files(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { path additions deletions changeType }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
PR_REVIEW_THREADS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query PRReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id isResolved isOutdated path line diffSide
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body path diffHunk
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
position originalPosition line originalLine
|
||||
commit { oid }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSIONS_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query DiscussionsPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussions(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body
|
||||
createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
locked
|
||||
answerChosenAt
|
||||
closed closedAt
|
||||
category { id name emoji description isAnswerable }
|
||||
labels(first: 30) { nodes { ...LabelFields } }
|
||||
upvoteCount
|
||||
answer { id databaseId body author { ...ActorFields } createdAt url }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
upvoteCount
|
||||
isAnswer
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
replies(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSION_COMMENTS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query DiscussionComments($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussion(number: $number) {
|
||||
comments(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
upvoteCount
|
||||
isAnswer
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
replies(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSION_REPLIES_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query DiscussionReplies($commentId: ID!, $after: String) {
|
||||
node(id: $commentId) {
|
||||
... on DiscussionComment {
|
||||
replies(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
COMMITS_PAGE_QUERY = """
|
||||
query CommitsPage($owner: String!, $name: String!, $first: Int!, $after: String, $branch: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
ref(qualifiedName: $branch) {
|
||||
target {
|
||||
... on Commit {
|
||||
history(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
oid
|
||||
message
|
||||
messageHeadline
|
||||
committedDate
|
||||
authoredDate
|
||||
url
|
||||
additions deletions changedFilesIfAvailable
|
||||
author { name email date user { login id } }
|
||||
committer { name email date user { login id } }
|
||||
parents(first: 3) { nodes { oid } }
|
||||
associatedPullRequests(first: 5) { nodes { number url state } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
RELEASES_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query Releases($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
releases(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId name tagName description
|
||||
createdAt publishedAt updatedAt
|
||||
isDraft isPrerelease isLatest
|
||||
url
|
||||
author { ...ActorFields }
|
||||
tagCommit { oid url }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
releaseAssets(first: 50) {
|
||||
nodes { name contentType size downloadUrl createdAt updatedAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
LABELS_QUERY = _q(
|
||||
[F_LABEL],
|
||||
"""
|
||||
query LabelsList($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
labels(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...LabelFields }
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
MILESTONES_QUERY = """
|
||||
query Milestones($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
milestones(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id number title description state
|
||||
createdAt updatedAt closedAt dueOn
|
||||
creator { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
REPO_META_QUERY = """
|
||||
query RepoMeta($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
id databaseId name nameWithOwner description url
|
||||
createdAt updatedAt pushedAt
|
||||
isArchived isDisabled isFork isPrivate
|
||||
primaryLanguage { name }
|
||||
languages(first: 20, orderBy: {field: SIZE, direction: DESC}) {
|
||||
edges { size node { name } }
|
||||
totalSize
|
||||
}
|
||||
stargazerCount forkCount watchers { totalCount }
|
||||
diskUsage
|
||||
licenseInfo { key name }
|
||||
homepageUrl
|
||||
defaultBranchRef { name }
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Scraper orchestration: issues, PRs, discussions, commits, releases, etc.
|
||||
|
||||
Resumable via state file. Writes JSONL shards under data/{repo}/{resource}.jsonl.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
# Allow running as a module or script
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
if str(THIS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(THIS_DIR))
|
||||
|
||||
from gh_client import GitHubClient
|
||||
from state_store import JsonlWriter, StateStore
|
||||
import queries as Q
|
||||
|
||||
log = logging.getLogger("scraper")
|
||||
|
||||
|
||||
def ts() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class RepoScraper:
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
name: str,
|
||||
base_dir: Path,
|
||||
client: GitHubClient,
|
||||
trial_limits: Optional[Dict[str, int]] = None,
|
||||
light: bool = False,
|
||||
):
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
self.base_dir = base_dir
|
||||
self.client = client
|
||||
self.trial_limits = trial_limits or {}
|
||||
# light=True uses trimmed GraphQL queries (no reviewThreads/reviews/
|
||||
# commits/timelineItems/files) so PR pages can be larger without
|
||||
# hitting GitHub's node-count ceiling.
|
||||
self.light = light
|
||||
self.repo_dir = base_dir / f"{owner}__{name}"
|
||||
self.repo_dir.mkdir(parents = True, exist_ok = True)
|
||||
self.state = StateStore(base_dir / "state" / f"{owner}__{name}.json")
|
||||
|
||||
# Writers
|
||||
self.writers: Dict[str, JsonlWriter] = {}
|
||||
for key in (
|
||||
"issues",
|
||||
"pull_requests",
|
||||
"discussions",
|
||||
"commits",
|
||||
"releases",
|
||||
"labels",
|
||||
"milestones",
|
||||
"pr_extra_comments",
|
||||
"pr_extra_timeline",
|
||||
"pr_extra_reviews",
|
||||
"issue_extra_comments",
|
||||
"issue_extra_timeline",
|
||||
"discussion_extra_comments",
|
||||
"discussion_extra_replies",
|
||||
"repo_meta",
|
||||
):
|
||||
self.writers[key] = JsonlWriter(self.repo_dir / f"{key}.jsonl")
|
||||
|
||||
# ----- helpers -----
|
||||
def _trial_stop(self, key: str, counter: int) -> bool:
|
||||
lim = self.trial_limits.get(key)
|
||||
if lim is None:
|
||||
return False
|
||||
return counter >= lim
|
||||
|
||||
def _log_rate(self, where: str, data: Dict[str, Any]) -> None:
|
||||
rl = data.get("data", {}).get("rateLimit") if isinstance(data.get("data"), dict) else None
|
||||
if rl:
|
||||
log.debug(
|
||||
"[%s] rate cost=%s remaining=%s resetAt=%s",
|
||||
where,
|
||||
rl.get("cost"),
|
||||
rl.get("remaining"),
|
||||
rl.get("resetAt"),
|
||||
)
|
||||
|
||||
# ----- repo meta -----
|
||||
def scrape_repo_meta(self) -> Dict[str, Any]:
|
||||
data = self.client.graphql(Q.REPO_META_QUERY, {"owner": self.owner, "name": self.name})
|
||||
self._log_rate("repo_meta", data)
|
||||
repo = data.get("data", {}).get("repository") or {}
|
||||
repo["_fetchedAt"] = ts()
|
||||
self.writers["repo_meta"].write(repo)
|
||||
return repo
|
||||
|
||||
# ----- issues -----
|
||||
def scrape_issues(self) -> int:
|
||||
key = "issues"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s issues already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
# Light query skips heavy nested fields; safe at 50/page. Clamp by
|
||||
# trial_limit so limit=1 asks for first:1, not a full 50-item page.
|
||||
page_cap = 50 if self.light else 15
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
query = Q.ISSUES_PAGE_QUERY_LIGHT if self.light else Q.ISSUES_PAGE_QUERY
|
||||
data = self.client.graphql(query, vars_)
|
||||
self._log_rate("issues", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
issues = repo.get("issues") or {}
|
||||
nodes = issues.get("nodes") or []
|
||||
for it in nodes:
|
||||
it["_owner"] = self.owner
|
||||
it["_repo"] = self.name
|
||||
it["_fetchedAt"] = ts()
|
||||
if not self.light:
|
||||
if it.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_issue_comments(
|
||||
it["number"], it["comments"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if it.get("timelineItems", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_issue_timeline(
|
||||
it["number"],
|
||||
it["timelineItems"]["pageInfo"]["endCursor"],
|
||||
)
|
||||
if self.writers[key].write(it):
|
||||
total_new += 1
|
||||
info = issues.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] issues page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
log.info("Trial limit reached for issues (%d)", total_new)
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_issue_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_COMMENTS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"issueOrPullRequest"
|
||||
) or {}
|
||||
comments = item.get("comments") or {}
|
||||
for c in comments.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_issueNumber"] = number
|
||||
self.writers["issue_extra_comments"].write(c)
|
||||
info = comments.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_issue_timeline(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_TIMELINE_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("issue") or {}
|
||||
tl = item.get("timelineItems") or {}
|
||||
for ev in tl.get("nodes") or []:
|
||||
ev["_owner"] = self.owner
|
||||
ev["_repo"] = self.name
|
||||
ev["_issueNumber"] = number
|
||||
self.writers["issue_extra_timeline"].write(ev)
|
||||
info = tl.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- PRs -----
|
||||
def scrape_prs(self) -> int:
|
||||
key = "pull_requests"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s PRs already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
# Heavy nested PR query caps at 3/page (GitHub node-count ceiling);
|
||||
# light query skips nested fields and goes to 25/page. Clamp by
|
||||
# trial_limit so limit=1 does not fetch a whole 25-item page.
|
||||
page_cap = 25 if self.light else 3
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
query = Q.PRS_PAGE_QUERY_LIGHT if self.light else Q.PRS_PAGE_QUERY
|
||||
data = self.client.graphql(query, vars_)
|
||||
self._log_rate("prs", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
prs = repo.get("pullRequests") or {}
|
||||
nodes = prs.get("nodes") or []
|
||||
for pr in nodes:
|
||||
pr["_owner"] = self.owner
|
||||
pr["_repo"] = self.name
|
||||
pr["_fetchedAt"] = ts()
|
||||
num = pr["number"]
|
||||
if not self.light:
|
||||
if pr.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_comments(num, pr["comments"]["pageInfo"]["endCursor"])
|
||||
if pr.get("timelineItems", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_timeline(
|
||||
num, pr["timelineItems"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if pr.get("commits", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_commits(num, pr["commits"]["pageInfo"]["endCursor"])
|
||||
if pr.get("files", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_files(num, pr["files"]["pageInfo"]["endCursor"])
|
||||
if pr.get("reviewThreads", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_review_threads(
|
||||
num, pr["reviewThreads"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if self.writers[key].write(pr):
|
||||
total_new += 1
|
||||
info = prs.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] PRs page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
log.info("Trial limit reached for PRs (%d)", total_new)
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_pr_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_COMMENTS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"issueOrPullRequest"
|
||||
) or {}
|
||||
comments = item.get("comments") or {}
|
||||
for c in comments.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_prNumber"] = number
|
||||
self.writers["pr_extra_comments"].write(c)
|
||||
info = comments.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_timeline(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_TIMELINE_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
|
||||
tl = item.get("timelineItems") or {}
|
||||
for ev in tl.get("nodes") or []:
|
||||
ev["_owner"] = self.owner
|
||||
ev["_repo"] = self.name
|
||||
ev["_prNumber"] = number
|
||||
self.writers["pr_extra_timeline"].write(ev)
|
||||
info = tl.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_commits(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_commits"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_COMMITS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
|
||||
cc = item.get("commits") or {}
|
||||
for c in cc.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_prNumber"] = number
|
||||
self.writers[out_key].write(c)
|
||||
info = cc.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_files(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_files"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_FILES_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
|
||||
ff = item.get("files") or {}
|
||||
for f in ff.get("nodes") or []:
|
||||
f["_owner"] = self.owner
|
||||
f["_repo"] = self.name
|
||||
f["_prNumber"] = number
|
||||
# files have no id; synthesize one
|
||||
f["_syntheticId"] = f"{self.owner}/{self.name}#{number}:{f.get('path')}"
|
||||
self.writers[out_key].write(f)
|
||||
info = ff.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_review_threads(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_review_threads"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_REVIEW_THREADS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
|
||||
rt = item.get("reviewThreads") or {}
|
||||
for th in rt.get("nodes") or []:
|
||||
th["_owner"] = self.owner
|
||||
th["_repo"] = self.name
|
||||
th["_prNumber"] = number
|
||||
self.writers[out_key].write(th)
|
||||
info = rt.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- Discussions -----
|
||||
def scrape_discussions(self) -> int:
|
||||
key = "discussions"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s discussions already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
per_page = 15
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSIONS_PAGE_QUERY, vars_)
|
||||
self._log_rate("discussions", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
dd = repo.get("discussions") or {}
|
||||
nodes = dd.get("nodes") or []
|
||||
for d in nodes:
|
||||
d["_owner"] = self.owner
|
||||
d["_repo"] = self.name
|
||||
d["_fetchedAt"] = ts()
|
||||
num = d["number"]
|
||||
if d.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_discussion_comments(num, d["comments"]["pageInfo"]["endCursor"])
|
||||
# paginate replies per comment if needed
|
||||
for c in d.get("comments", {}).get("nodes", []) or []:
|
||||
if c.get("replies", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_discussion_replies(
|
||||
c["id"], c["replies"]["pageInfo"]["endCursor"], num
|
||||
)
|
||||
if self.writers[key].write(d):
|
||||
total_new += 1
|
||||
info = dd.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] discussions page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_discussion_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSION_COMMENTS_QUERY, vars_)
|
||||
disc = ((data.get("data") or {}).get("repository") or {}).get("discussion") or {}
|
||||
cc = disc.get("comments") or {}
|
||||
for c in cc.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_discussionNumber"] = number
|
||||
self.writers["discussion_extra_comments"].write(c)
|
||||
info = cc.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_discussion_replies(self, comment_id: str, after: str, disc_number: int) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"commentId": comment_id,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSION_REPLIES_QUERY, vars_)
|
||||
node = (data.get("data") or {}).get("node") or {}
|
||||
replies = node.get("replies") or {}
|
||||
for r in replies.get("nodes") or []:
|
||||
r["_owner"] = self.owner
|
||||
r["_repo"] = self.name
|
||||
r["_discussionNumber"] = disc_number
|
||||
r["_commentId"] = comment_id
|
||||
self.writers["discussion_extra_replies"].write(r)
|
||||
info = replies.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- Commits -----
|
||||
def scrape_commits(self, branch: str = "refs/heads/main") -> int:
|
||||
key = "commits"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
page_cap = 100
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
"branch": branch,
|
||||
}
|
||||
data = self.client.graphql(Q.COMMITS_PAGE_QUERY, vars_)
|
||||
self._log_rate("commits", data)
|
||||
ref = ((data.get("data") or {}).get("repository") or {}).get("ref") or {}
|
||||
tgt = ref.get("target") or {}
|
||||
hist = tgt.get("history") or {}
|
||||
nodes = hist.get("nodes") or []
|
||||
for c in nodes:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_fetchedAt"] = ts()
|
||||
if self.writers[key].write(c):
|
||||
total_new += 1
|
||||
info = hist.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] commits page %d (+%d) remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
# ----- Releases/Labels/Milestones -----
|
||||
def scrape_releases(self) -> int:
|
||||
return self._scrape_simple("releases", Q.RELEASES_QUERY, "releases")
|
||||
|
||||
def scrape_labels(self) -> int:
|
||||
return self._scrape_simple("labels", Q.LABELS_QUERY, "labels")
|
||||
|
||||
def scrape_milestones(self) -> int:
|
||||
return self._scrape_simple("milestones", Q.MILESTONES_QUERY, "milestones")
|
||||
|
||||
def _scrape_simple(self, key: str, query: str, field: str) -> int:
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
return 0
|
||||
total_new = 0
|
||||
while True:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": 50,
|
||||
"after": cursor,
|
||||
}
|
||||
data = self.client.graphql(query, vars_)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
col = repo.get(field) or {}
|
||||
for it in col.get("nodes") or []:
|
||||
it["_owner"] = self.owner
|
||||
it["_repo"] = self.name
|
||||
it["_fetchedAt"] = ts()
|
||||
if self.writers[key].write(it):
|
||||
total_new += 1
|
||||
info = col.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
log.info("[%s/%s] %s done +%d", self.owner, self.name, key, total_new)
|
||||
return total_new
|
||||
|
||||
def close(self) -> None:
|
||||
for w in self.writers.values():
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging(log_file: Path) -> None:
|
||||
log_file.parent.mkdir(parents = True, exist_ok = True)
|
||||
fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||
handlers = [
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_file, mode = "a", encoding = "utf-8"),
|
||||
]
|
||||
logging.basicConfig(level = logging.INFO, format = fmt, handlers = handlers, force = True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base-dir", default = "/mnt/disks/unslothai/ubuntu/workspace_34/github_scraper")
|
||||
ap.add_argument("--repos", nargs = "+", default = ["unslothai/unsloth", "unslothai/unsloth-zoo"])
|
||||
ap.add_argument("--trial", action = "store_true", help = "Small trial run")
|
||||
ap.add_argument(
|
||||
"--only",
|
||||
nargs = "+",
|
||||
default = None,
|
||||
help = "Only run these resource keys: issues,pulls,discussions,commits,releases,labels,milestones,meta",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--hf-upload-interval",
|
||||
type = int,
|
||||
default = 900,
|
||||
help = "Seconds between HF uploads (0 to disable)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
base = Path(args.base_dir)
|
||||
data_dir = base / "data"
|
||||
data_dir.mkdir(parents = True, exist_ok = True)
|
||||
setup_logging(base / "logs" / f"scraper_{time.strftime('%Y%m%d_%H%M%S')}.log")
|
||||
log.info("Scraper starting: repos=%s trial=%s", args.repos, args.trial)
|
||||
|
||||
client = GitHubClient(min_remaining_graphql = 80, min_remaining_rest = 80)
|
||||
rl = client.rate_snapshot()
|
||||
log.info(
|
||||
"Rate limit snapshot: %s",
|
||||
json.dumps(rl.get("resources", {}), default = str)[:400],
|
||||
)
|
||||
|
||||
# Start HF uploader in background if requested
|
||||
uploader = None
|
||||
if args.hf_upload_interval > 0:
|
||||
from hf_uploader import HFUploader
|
||||
uploader = HFUploader(data_dir, interval_s = args.hf_upload_interval)
|
||||
uploader.start()
|
||||
|
||||
trial_limits = None
|
||||
if args.trial:
|
||||
trial_limits = {
|
||||
"issues": 5,
|
||||
"pull_requests": 5,
|
||||
"discussions": 3,
|
||||
"commits": 20,
|
||||
"releases": 3,
|
||||
"labels": 20,
|
||||
"milestones": 20,
|
||||
}
|
||||
|
||||
only = set(args.only or [])
|
||||
|
||||
try:
|
||||
for repo_spec in args.repos:
|
||||
owner, name = repo_spec.split("/")
|
||||
scraper = RepoScraper(owner, name, data_dir, client, trial_limits)
|
||||
try:
|
||||
repo_meta: Dict[str, Any] = {}
|
||||
if not only or "meta" in only or "commits" in only:
|
||||
repo_meta = scraper.scrape_repo_meta()
|
||||
if not only or "labels" in only:
|
||||
scraper.scrape_labels()
|
||||
if not only or "milestones" in only:
|
||||
scraper.scrape_milestones()
|
||||
if not only or "releases" in only:
|
||||
scraper.scrape_releases()
|
||||
if not only or "discussions" in only:
|
||||
scraper.scrape_discussions()
|
||||
if not only or "issues" in only:
|
||||
scraper.scrape_issues()
|
||||
if not only or "pulls" in only:
|
||||
scraper.scrape_prs()
|
||||
if not only or "commits" in only:
|
||||
default_ref = repo_meta.get("defaultBranchRef") or {}
|
||||
default_branch = (
|
||||
default_ref.get("name") if isinstance(default_ref, dict) else None
|
||||
)
|
||||
branch = f"refs/heads/{default_branch}" if default_branch else "refs/heads/main"
|
||||
scraper.scrape_commits(branch = branch)
|
||||
finally:
|
||||
scraper.close()
|
||||
finally:
|
||||
if uploader:
|
||||
log.info("Stopping uploader and final sync...")
|
||||
uploader.stop(final_upload = True)
|
||||
log.info(
|
||||
"Scraper complete. GraphQL calls=%d REST calls=%d",
|
||||
client.calls_graphql,
|
||||
client.calls_rest,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Checkpoint state management for resumable scraping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {}
|
||||
if self.path.exists():
|
||||
try:
|
||||
with self.path.open() as f:
|
||||
self._data = json.load(f)
|
||||
except Exception:
|
||||
self._data = {}
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: str,
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
with self._lock:
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
with self._lock:
|
||||
self._data[key] = value
|
||||
self._flush()
|
||||
|
||||
def update(self, key: str, **kwargs) -> None:
|
||||
with self._lock:
|
||||
sub = dict(self._data.get(key, {}))
|
||||
sub.update(kwargs)
|
||||
self._data[key] = sub
|
||||
self._flush()
|
||||
|
||||
def all(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._data)
|
||||
|
||||
def _flush(self) -> None:
|
||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
with tmp.open("w") as f:
|
||||
json.dump(self._data, f, indent = 2, default = str)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
|
||||
class JsonlWriter:
|
||||
"""Append-only JSONL writer, thread-safe, with line buffering."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._fh = self.path.open("a", buffering = 1)
|
||||
self._count_seen_keys: set[str] = set()
|
||||
# Preload seen keys for dedup across resumes
|
||||
if self.path.exists() and self.path.stat().st_size > 0:
|
||||
try:
|
||||
with self.path.open() as f:
|
||||
for line in f:
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
k = self._key(obj)
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _key(self, obj: dict) -> str | None:
|
||||
for k in ("id", "node_id", "number", "sha", "url"):
|
||||
if k in obj:
|
||||
return f"{k}:{obj[k]}"
|
||||
return None
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return key in self._count_seen_keys
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Return True if newly written, False if already present."""
|
||||
k = self._key(obj)
|
||||
with self._lock:
|
||||
if k is not None and k in self._count_seen_keys:
|
||||
return False
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
self._fh.write(json.dumps(obj, default = str, ensure_ascii = False))
|
||||
self._fh.write("\n")
|
||||
self._fh.flush()
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-designer-unstructured-seed"
|
||||
version = "0.1.0"
|
||||
description = "Local Data Designer unstructured seed reader plugin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"data-designer-engine>=0.5.4,<0.6",
|
||||
"pandas>=2,<3",
|
||||
"pymupdf>=1.24.0",
|
||||
"pymupdf4llm>=0.0.17",
|
||||
"mammoth>=1.8.0",
|
||||
]
|
||||
|
||||
[project.entry-points."data_designer.plugins"]
|
||||
unstructured = "data_designer_unstructured_seed.plugin:unstructured_seed_plugin"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from .chunking import (
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
build_unstructured_preview_rows,
|
||||
materialize_unstructured_seed_dataset,
|
||||
resolve_chunking,
|
||||
)
|
||||
from .config import UnstructuredSeedSource
|
||||
from .impl import UnstructuredSeedReader
|
||||
from .plugin import unstructured_seed_plugin
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CHUNK_OVERLAP",
|
||||
"DEFAULT_CHUNK_SIZE",
|
||||
"build_unstructured_preview_rows",
|
||||
"materialize_unstructured_seed_dataset",
|
||||
"resolve_chunking",
|
||||
"UnstructuredSeedSource",
|
||||
"UnstructuredSeedReader",
|
||||
"unstructured_seed_plugin",
|
||||
]
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from utils.paths import ensure_dir, unstructured_seed_cache_root
|
||||
|
||||
DEFAULT_CHUNK_SIZE = 1200
|
||||
DEFAULT_CHUNK_OVERLAP = 200
|
||||
MAX_CHUNK_SIZE = 20000
|
||||
_MIN_BREAK_RATIO = 0.6
|
||||
_CACHE_DIR = unstructured_seed_cache_root()
|
||||
|
||||
|
||||
def resolve_chunking(chunk_size: Any, chunk_overlap: Any) -> tuple[int, int]:
|
||||
size = _to_int(chunk_size, DEFAULT_CHUNK_SIZE)
|
||||
size = max(1, min(size, MAX_CHUNK_SIZE))
|
||||
overlap = _to_int(chunk_overlap, DEFAULT_CHUNK_OVERLAP)
|
||||
overlap = max(0, min(overlap, max(0, size - 1)))
|
||||
return size, overlap
|
||||
|
||||
|
||||
def build_unstructured_preview_rows(
|
||||
*, source_path: Path, preview_size: int, chunk_size: Any, chunk_overlap: Any
|
||||
) -> list[dict[str, str]]:
|
||||
parquet_path, rows = materialize_unstructured_seed_dataset(
|
||||
source_path = source_path,
|
||||
chunk_size = chunk_size,
|
||||
chunk_overlap = chunk_overlap,
|
||||
)
|
||||
count = max(0, int(preview_size))
|
||||
if rows:
|
||||
return rows[:count]
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(f"pandas is required for unstructured seed processing: {exc}") from exc
|
||||
|
||||
dataframe = pd.read_parquet(parquet_path).head(count)
|
||||
return [
|
||||
{"chunk_text": str(value.get("chunk_text", "")).strip()}
|
||||
for value in dataframe.to_dict(orient = "records")
|
||||
if str(value.get("chunk_text", "")).strip()
|
||||
]
|
||||
|
||||
|
||||
def build_multi_file_preview_rows(
|
||||
*,
|
||||
file_entries: list[tuple[Path, str]],
|
||||
preview_size: int,
|
||||
chunk_size: int | None,
|
||||
chunk_overlap: int | None,
|
||||
) -> list[dict[str, str]]:
|
||||
cs = _to_int(chunk_size, DEFAULT_CHUNK_SIZE)
|
||||
co = _to_int(chunk_overlap, DEFAULT_CHUNK_OVERLAP)
|
||||
_, rows = materialize_multi_file_unstructured_seed(
|
||||
file_entries = file_entries,
|
||||
chunk_size = cs,
|
||||
chunk_overlap = co,
|
||||
)
|
||||
return _round_robin_preview(rows, preview_size)
|
||||
|
||||
|
||||
def _round_robin_preview(rows: list[dict[str, str]], preview_size: int) -> list[dict[str, str]]:
|
||||
"""Pick preview rows round-robin across source files so each is represented."""
|
||||
if not rows or preview_size <= 0:
|
||||
return []
|
||||
|
||||
# Group by source_file, preserving first-appearance order.
|
||||
from collections import OrderedDict
|
||||
|
||||
grouped: OrderedDict[str, list[dict[str, str]]] = OrderedDict()
|
||||
for row in rows:
|
||||
key = row.get("source_file", "")
|
||||
if key not in grouped:
|
||||
grouped[key] = []
|
||||
grouped[key].append(row)
|
||||
|
||||
result: list[dict[str, str]] = []
|
||||
iterators = [iter(chunks) for chunks in grouped.values()]
|
||||
while len(result) < preview_size and iterators:
|
||||
exhausted: list[int] = []
|
||||
for i, it in enumerate(iterators):
|
||||
if len(result) >= preview_size:
|
||||
break
|
||||
val = next(it, None)
|
||||
if val is not None:
|
||||
result.append(val)
|
||||
else:
|
||||
exhausted.append(i)
|
||||
for i in reversed(exhausted):
|
||||
iterators.pop(i)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def materialize_unstructured_seed_dataset(
|
||||
*, source_path: Path, chunk_size: Any, chunk_overlap: Any
|
||||
) -> tuple[Path, list[dict[str, str]]]:
|
||||
resolved = source_path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
raise FileNotFoundError(f"unstructured seed file not found: {resolved}")
|
||||
|
||||
size, overlap = resolve_chunking(chunk_size, chunk_overlap)
|
||||
key = _compute_cache_key(
|
||||
source_path = resolved,
|
||||
chunk_size = size,
|
||||
chunk_overlap = overlap,
|
||||
)
|
||||
parquet_path = _CACHE_DIR / f"{key}.parquet"
|
||||
if parquet_path.exists():
|
||||
return parquet_path, []
|
||||
|
||||
text = load_unstructured_text_file(resolved)
|
||||
chunks = split_text_into_chunks(
|
||||
text = text,
|
||||
chunk_size = size,
|
||||
chunk_overlap = overlap,
|
||||
)
|
||||
if not chunks:
|
||||
raise ValueError("No text found in unstructured seed source.")
|
||||
|
||||
rows = [{"chunk_text": chunk} for chunk in chunks]
|
||||
ensure_dir(_CACHE_DIR)
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(f"pandas is required for unstructured seed processing: {exc}") from exc
|
||||
|
||||
tmp_path = _CACHE_DIR / f"{key}.tmp.parquet"
|
||||
pd.DataFrame(rows).to_parquet(tmp_path, index = False)
|
||||
tmp_path.replace(parquet_path)
|
||||
return parquet_path, rows
|
||||
|
||||
|
||||
def materialize_multi_file_unstructured_seed(
|
||||
*,
|
||||
file_entries: list[tuple[Path, str]], # (extracted_txt_path, original_filename)
|
||||
chunk_size: int,
|
||||
chunk_overlap: int,
|
||||
) -> tuple[Path, list[dict[str, str]]]:
|
||||
"""Chunk multiple files into one parquet dataset with a source_file column."""
|
||||
chunk_size, chunk_overlap = resolve_chunking(chunk_size, chunk_overlap)
|
||||
cache_key = _compute_multi_file_cache_key(file_entries, chunk_size, chunk_overlap)
|
||||
cached = _CACHE_DIR / f"{cache_key}.parquet"
|
||||
if cached.exists():
|
||||
df = pd.read_parquet(cached)
|
||||
rows = df.to_dict(orient = "records")
|
||||
return cached, rows
|
||||
|
||||
all_rows: list[dict[str, str]] = []
|
||||
for txt_path, orig_name in file_entries:
|
||||
text = load_unstructured_text_file(txt_path)
|
||||
chunks = split_text_into_chunks(
|
||||
text = text,
|
||||
chunk_size = chunk_size,
|
||||
chunk_overlap = chunk_overlap,
|
||||
)
|
||||
for chunk in chunks:
|
||||
all_rows.append({"chunk_text": chunk, "source_file": orig_name})
|
||||
|
||||
if not all_rows:
|
||||
raise ValueError("No text found in any uploaded files.")
|
||||
|
||||
df = pd.DataFrame(all_rows)
|
||||
ensure_dir(_CACHE_DIR)
|
||||
tmp = _CACHE_DIR / f"{cache_key}.tmp.parquet"
|
||||
df.to_parquet(tmp, index = False)
|
||||
tmp.replace(cached)
|
||||
return cached, all_rows
|
||||
|
||||
|
||||
def load_unstructured_text_file(path: Path) -> str:
|
||||
ext = path.suffix.lower()
|
||||
if ext not in {".txt", ".md"}:
|
||||
raise ValueError(f"Unsupported unstructured seed file type: {ext}")
|
||||
|
||||
raw = path.read_text(encoding = "utf-8", errors = "ignore")
|
||||
return normalize_unstructured_text(raw)
|
||||
|
||||
|
||||
def normalize_unstructured_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return re.sub(r"\n{3,}", "\n\n", normalized).strip()
|
||||
|
||||
|
||||
def split_text_into_chunks(*, text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
if chunk_size <= 0:
|
||||
return [text]
|
||||
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
min_break_index = int(chunk_size * _MIN_BREAK_RATIO)
|
||||
text_len = len(text)
|
||||
while start < text_len:
|
||||
end = min(text_len, start + chunk_size)
|
||||
if end < text_len:
|
||||
window = text[start:end]
|
||||
cut = _find_break_index(window, min_break_index)
|
||||
if cut is not None and cut > 0:
|
||||
end = start + cut
|
||||
|
||||
if end <= start:
|
||||
end = min(text_len, start + chunk_size)
|
||||
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
if end >= text_len:
|
||||
break
|
||||
|
||||
next_start = end - chunk_overlap
|
||||
if next_start <= start:
|
||||
next_start = end
|
||||
start = max(0, next_start)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _find_break_index(window: str, min_index: int) -> int | None:
|
||||
breakpoints = ["\n\n", "\n", " "]
|
||||
for token in breakpoints:
|
||||
idx = window.rfind(token)
|
||||
if idx >= min_index:
|
||||
return idx + len(token)
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any, fallback: int) -> int:
|
||||
if isinstance(value, bool):
|
||||
return fallback
|
||||
try:
|
||||
parsed = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return parsed
|
||||
|
||||
|
||||
def _compute_cache_key(*, source_path: Path, chunk_size: int, chunk_overlap: int) -> str:
|
||||
stat = source_path.stat()
|
||||
payload = "|".join(
|
||||
[
|
||||
str(source_path),
|
||||
str(stat.st_size),
|
||||
str(stat.st_mtime_ns),
|
||||
str(chunk_size),
|
||||
str(chunk_overlap),
|
||||
]
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _compute_multi_file_cache_key(
|
||||
file_entries: list[tuple[Path, str]], chunk_size: int, chunk_overlap: int
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for path, name in sorted(file_entries, key = lambda e: e[1]):
|
||||
st = path.stat()
|
||||
parts.append(f"{path}|{st.st_size}|{st.st_mtime_ns}|{name}")
|
||||
parts.append(f"cs={chunk_size}|co={chunk_overlap}")
|
||||
raw = "\n".join(parts)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from data_designer.config.seed_source import SeedSource
|
||||
|
||||
from .chunking import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, resolve_chunking
|
||||
|
||||
|
||||
class UnstructuredSeedSource(SeedSource):
|
||||
seed_type: Literal["unstructured"] = "unstructured"
|
||||
paths: list[str] = Field(min_length = 1)
|
||||
|
||||
@model_validator(mode = "before")
|
||||
@classmethod
|
||||
def _normalize_legacy_path(cls, data):
|
||||
if isinstance(data, dict) and "paths" not in data and data.get("path"):
|
||||
data = dict(data)
|
||||
data["paths"] = [data["path"]]
|
||||
return data
|
||||
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE
|
||||
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP
|
||||
|
||||
@field_validator("paths")
|
||||
@classmethod
|
||||
def _validate_paths(cls, v: list[str]) -> list[str]:
|
||||
for p in v:
|
||||
expanded = Path(p).expanduser()
|
||||
if not expanded.is_file():
|
||||
raise ValueError(f"Seed file does not exist: {expanded}")
|
||||
return v
|
||||
|
||||
@field_validator("chunk_size")
|
||||
@classmethod
|
||||
def _resolve_chunk_size(cls, v: int) -> int:
|
||||
cs, _ = resolve_chunking(v, 0)
|
||||
return cs
|
||||
|
||||
@field_validator("chunk_overlap")
|
||||
@classmethod
|
||||
def _resolve_chunk_overlap(cls, v: int, info) -> int:
|
||||
cs = info.data.get("chunk_size", DEFAULT_CHUNK_SIZE)
|
||||
_, co = resolve_chunking(cs, v)
|
||||
return co
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import data_designer.lazy_heavy_imports as lazy
|
||||
from data_designer.engine.resources.seed_reader import SeedReader
|
||||
|
||||
from .config import UnstructuredSeedSource
|
||||
|
||||
|
||||
class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]):
|
||||
def create_duckdb_connection(self):
|
||||
return lazy.duckdb.connect()
|
||||
|
||||
def get_dataset_uri(self) -> str:
|
||||
from .chunking import materialize_multi_file_unstructured_seed
|
||||
import json as json_mod
|
||||
|
||||
file_entries: list[tuple[Path, str]] = []
|
||||
for p in self.source.paths:
|
||||
path_obj = Path(p)
|
||||
file_id = path_obj.name.replace(".extracted.txt", "")
|
||||
meta_path = path_obj.parent / f"{file_id}.meta.json"
|
||||
orig_name = path_obj.name
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json_mod.loads(meta_path.read_text())
|
||||
orig_name = meta.get("original_filename", path_obj.name)
|
||||
except (json_mod.JSONDecodeError, OSError):
|
||||
pass
|
||||
file_entries.append((path_obj, orig_name))
|
||||
|
||||
path, _ = materialize_multi_file_unstructured_seed(
|
||||
file_entries = file_entries,
|
||||
chunk_size = self.source.chunk_size,
|
||||
chunk_overlap = self.source.chunk_overlap,
|
||||
)
|
||||
return str(path)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from data_designer.plugins.plugin import Plugin, PluginType
|
||||
|
||||
unstructured_seed_plugin = Plugin(
|
||||
impl_qualified_name = "data_designer_unstructured_seed.impl.UnstructuredSeedReader",
|
||||
config_qualified_name = "data_designer_unstructured_seed.config.UnstructuredSeedSource",
|
||||
plugin_type = PluginType.SEED_READER,
|
||||
)
|
||||
Reference in New Issue
Block a user