mirror of
https://github.com/lingzhi227/agent-research-skills.git
synced 2026-09-20 09:33:06 +08:00
Add github-research skill (v1.1.0)
6-phase pipeline for discovering, evaluating, and deeply analyzing GitHub repositories related to a research topic. Reads deep-research output, discovers repos from multiple sources (GitHub search, code search, Papers With Code), scores and ranks them, clones and analyzes code, then produces cross-repo comparison and integration blueprints. 13 Python scripts (stdlib-only), 1 SKILL.md, 1 phase-guide reference.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
github/
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.1.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- **github-research skill**: 6-phase pipeline for discovering, evaluating, and deeply analyzing GitHub repositories related to a research topic
|
||||
- Phase 1: Intake — extract refs, URLs, keywords from deep-research output
|
||||
- Phase 2: Discovery — multi-source GitHub search (repos, code, Papers With Code)
|
||||
- Phase 3: Filtering — score, rank, and select top repos
|
||||
- Phase 4: Deep Dive — clone and deeply analyze code structure, dependencies, implementations
|
||||
- Phase 5: Analysis — cross-repo comparison matrix, technique mapping, gap analysis
|
||||
- Phase 6: Blueprint — integration plan, reusable components catalog
|
||||
- **13 Python scripts** for github-research: `extract_research_refs.py`, `search_github.py`, `search_github_code.py`, `search_paperswithcode.py`, `repo_db.py`, `repo_metadata.py`, `repo_readme_fetch.py`, `clone_repo.py`, `analyze_repo_structure.py`, `extract_dependencies.py`, `find_implementations.py`, `compare_repos.py`, `compile_github_report.py`
|
||||
- **Phase guide reference** (`references/phase-guide.md`) with search strategy matrix and per-repo analysis templates
|
||||
|
||||
## [1.0.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# agent-research-skills
|
||||
|
||||
30 skills for [Claude Code](https://claude.ai/code) covering the full academic research paper lifecycle — from literature search to slide generation.
|
||||
31 skills for [Claude Code](https://claude.ai/code) covering the full academic research paper lifecycle — from literature search to slide generation — plus GitHub repository analysis for research topics.
|
||||
|
||||
Extracted from 17 GitHub repos studying LLM-agent-driven research automation. See [SKILLS_DESIGN.md](/Users/lingzhi/Code/research-engine/SKILLS_DESIGN.md) for the original design specifications.
|
||||
|
||||
@@ -40,12 +40,13 @@ This installs slash commands, checks Python dependencies, and verifies script sy
|
||||
|
||||
2. **Output directory**: Deep research outputs go to `~/deep-research-output/` by default.
|
||||
|
||||
## Available Skills (30)
|
||||
## Available Skills (31)
|
||||
|
||||
### Phase 0: Research Discovery & Planning
|
||||
|
||||
| Skill | Description | Scripts |
|
||||
|-------|-------------|---------|
|
||||
| **[github-research](skills/github-research/)** | 6-phase GitHub repo discovery, analysis, and integration planning for research topics | 13 scripts: search, clone, analyze structure/deps/impls, compare, compile report |
|
||||
| **[deep-research](skills/deep-research/)** | 6-phase systematic literature survey (frontier → survey → deep dive → code → synthesis → report) | 7 scripts: search APIs, PDF extraction, paper DB, BibTeX, report compilation |
|
||||
| **[literature-search](skills/literature-search/)** | Multi-source academic search (Semantic Scholar, arXiv, OpenAlex, CrossRef) with ranking | 4 scripts: `search_crossref.py`, `download_arxiv_source.py`, `search_openalex.py` + shared |
|
||||
| **[literature-review](skills/literature-review/)** | Multi-perspective dialogue simulation with expert personas for grounded literature review | Shares search scripts |
|
||||
@@ -118,6 +119,7 @@ This installs slash commands, checks Python dependencies, and verifies script sy
|
||||
### Natural language (skills activate automatically)
|
||||
|
||||
```
|
||||
"Analyze GitHub repos for multi-agent coordination research"
|
||||
"Do a literature review on protein folding with LLMs"
|
||||
"Write the Methods section of my paper"
|
||||
"Generate a comparison table from results.json"
|
||||
@@ -130,6 +132,12 @@ This installs slash commands, checks Python dependencies, and verifies script sy
|
||||
### Direct script usage
|
||||
|
||||
```bash
|
||||
# Search GitHub repos for a research topic
|
||||
python ~/.claude/skills/github-research/scripts/search_github.py --query "multi-agent LLM coordination" --max-results 50 --output repos.jsonl
|
||||
|
||||
# Analyze a cloned repo's structure
|
||||
python ~/.claude/skills/github-research/scripts/analyze_repo_structure.py --repo-dir ./my-repo --output analysis.json
|
||||
|
||||
# Search literature
|
||||
python ~/.claude/skills/literature-search/scripts/search_crossref.py --query "attention mechanism" --rows 10
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
---
|
||||
name: github-research
|
||||
description: Explore and analyze GitHub repositories related to a research topic. Reads deep-research output, discovers repos from multiple sources, deeply analyzes code, and produces integration blueprints.
|
||||
argument-hint: [deep-research-output-dir]
|
||||
---
|
||||
|
||||
# GitHub Research Skill
|
||||
|
||||
## Trigger
|
||||
|
||||
Activate this skill when the user wants to:
|
||||
- "Find repos for [topic]", "GitHub research on [topic]"
|
||||
- "Analyze open-source code for [topic]"
|
||||
- "Find implementations of [paper/technique]"
|
||||
- "Which repos implement [algorithm]?"
|
||||
- Uses `/github-research <deep-research-output-dir>` slash command
|
||||
|
||||
## Overview
|
||||
|
||||
This skill systematically discovers, evaluates, and deeply analyzes GitHub repositories related to a research topic. It reads **deep-research** output (paper database, phase reports, code references) and produces an actionable integration blueprint for reusing open-source code.
|
||||
|
||||
**Installation**: `~/.claude/skills/github-research/` — scripts, references, and this skill definition.
|
||||
**Output**: `./github-research-output/{slug}/` relative to the current working directory.
|
||||
**Input**: A deep-research output directory (containing `paper_db.jsonl`, phase reports, `code_repos.md`, etc.)
|
||||
|
||||
## 6-Phase Pipeline
|
||||
|
||||
```
|
||||
Phase 1: Intake → Extract refs, URLs, keywords from deep-research output
|
||||
Phase 2: Discovery → Multi-source broad GitHub search (50-200 repos)
|
||||
Phase 3: Filtering → Score & rank → select top 15-30 repos
|
||||
Phase 4: Deep Dive → Clone & deeply analyze top 8-15 repos (code reading)
|
||||
Phase 5: Analysis → Per-repo reports + cross-repo comparison
|
||||
Phase 6: Blueprint → Integration/reuse plan for research topic
|
||||
```
|
||||
|
||||
## Output Directory Structure
|
||||
|
||||
```
|
||||
github-research-output/{slug}/
|
||||
├── repo_db.jsonl # Master repo database
|
||||
├── phase1_intake/
|
||||
│ ├── extracted_refs.jsonl # URLs, keywords, paper-repo links
|
||||
│ └── intake_summary.md
|
||||
├── phase2_discovery/
|
||||
│ ├── search_results/ # Raw JSONL from each search
|
||||
│ └── discovery_log.md
|
||||
├── phase3_filtering/
|
||||
│ ├── ranked_repos.jsonl # Scored & ranked subset
|
||||
│ └── filtering_report.md
|
||||
├── phase4_deep_dive/
|
||||
│ ├── repos/ # Cloned repos (shallow)
|
||||
│ ├── analyses/ # Per-repo analysis .md files
|
||||
│ └── deep_dive_summary.md
|
||||
├── phase5_analysis/
|
||||
│ ├── comparison_matrix.md # Cross-repo comparison
|
||||
│ ├── technique_map.md # Paper concept → code mapping
|
||||
│ └── analysis_report.md
|
||||
└── phase6_blueprint/
|
||||
├── integration_plan.md # How to combine repos
|
||||
├── reuse_catalog.md # Reusable components catalog
|
||||
├── final_report.md # Complete compiled report
|
||||
└── blueprint_summary.md
|
||||
```
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
All scripts are Python 3, stdlib-only, located in `~/.claude/skills/github-research/scripts/`.
|
||||
|
||||
| Script | Purpose | Key Flags |
|
||||
|--------|---------|-----------|
|
||||
| `extract_research_refs.py` | Parse deep-research output for GitHub URLs, paper refs, keywords | `--research-dir`, `--output` |
|
||||
| `search_github.py` | Search GitHub repos via `gh api` | `--query`, `--language`, `--min-stars`, `--sort`, `--max-results`, `--topic`, `--output` |
|
||||
| `search_github_code.py` | Search GitHub code for implementations | `--query`, `--language`, `--filename`, `--max-results`, `--output` |
|
||||
| `search_paperswithcode.py` | Search Papers With Code for paper→repo mappings | `--paper-title`, `--arxiv-id`, `--query`, `--output` |
|
||||
| `repo_db.py` | JSONL repo database management | subcommands: `merge`, `filter`, `score`, `search`, `tag`, `stats`, `export`, `rank` |
|
||||
| `repo_metadata.py` | Fetch detailed metadata via `gh api` | `--repos`, `--input`, `--output`, `--delay` |
|
||||
| `clone_repo.py` | Shallow-clone repos for analysis | `--repo`, `--output-dir`, `--depth`, `--branch` |
|
||||
| `analyze_repo_structure.py` | Map file tree, key files, LOC stats | `--repo-dir`, `--output` |
|
||||
| `extract_dependencies.py` | Extract and parse dependency files | `--repo-dir`, `--output` |
|
||||
| `find_implementations.py` | Search cloned repo for specific code patterns | `--repo-dir`, `--patterns`, `--output` |
|
||||
| `repo_readme_fetch.py` | Fetch README without cloning | `--repos`, `--input`, `--output`, `--max-chars` |
|
||||
| `compare_repos.py` | Generate comparison matrix across repos | `--input`, `--output` |
|
||||
| `compile_github_report.py` | Assemble final report from all phases | `--topic-dir` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Intake
|
||||
|
||||
**Goal**: Extract all relevant references, URLs, and keywords from the deep-research output.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create output directory structure**:
|
||||
```bash
|
||||
SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
|
||||
mkdir -p github-research-output/$SLUG/{phase1_intake,phase2_discovery/search_results,phase3_filtering,phase4_deep_dive/{repos,analyses},phase5_analysis,phase6_blueprint}
|
||||
```
|
||||
|
||||
2. **Extract references from deep-research output**:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/extract_research_refs.py \
|
||||
--research-dir <deep-research-output-dir> \
|
||||
--output github-research-output/$SLUG/phase1_intake/extracted_refs.jsonl
|
||||
```
|
||||
|
||||
3. **Review extracted refs**: Read the generated JSONL. Note:
|
||||
- GitHub URLs found directly in reports
|
||||
- Paper titles and arxiv IDs (for Papers With Code lookup)
|
||||
- Research keywords and themes (for GitHub search queries)
|
||||
|
||||
4. **Write intake summary**: Create `phase1_intake/intake_summary.md` with:
|
||||
- Number of direct GitHub URLs found
|
||||
- Number of papers with potential code links
|
||||
- Key research themes extracted
|
||||
- Planned search queries for Phase 2
|
||||
|
||||
### Checkpoint
|
||||
- `extracted_refs.jsonl` exists with entries
|
||||
- `intake_summary.md` written
|
||||
- Search strategy documented
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Discovery
|
||||
|
||||
**Goal**: Cast a wide net to find 50-200 candidate repos from multiple sources.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Search by direct URLs**: Any GitHub URLs from Phase 1 → fetch metadata:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_metadata.py \
|
||||
--repos owner1/name1 owner2/name2 ... \
|
||||
--output github-research-output/$SLUG/phase2_discovery/search_results/direct_urls.jsonl
|
||||
```
|
||||
|
||||
2. **Search Papers With Code**: For each paper with an arxiv ID:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/search_paperswithcode.py \
|
||||
--arxiv-id 2401.12345 \
|
||||
--output github-research-output/$SLUG/phase2_discovery/search_results/pwc_2401.12345.jsonl
|
||||
```
|
||||
|
||||
3. **Search GitHub by keywords** (3-8 queries based on research themes):
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/search_github.py \
|
||||
--query "multi-agent LLM coordination" \
|
||||
--min-stars 10 --sort stars --max-results 50 \
|
||||
--output github-research-output/$SLUG/phase2_discovery/search_results/gh_query1.jsonl
|
||||
```
|
||||
|
||||
4. **Search GitHub code** (for specific implementations):
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/search_github_code.py \
|
||||
--query "class MultiAgentOrchestrator" \
|
||||
--language python --max-results 30 \
|
||||
--output github-research-output/$SLUG/phase2_discovery/search_results/code_query1.jsonl
|
||||
```
|
||||
|
||||
5. **Fetch READMEs** for repos that lack descriptions:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_readme_fetch.py \
|
||||
--input <repos.jsonl> \
|
||||
--output github-research-output/$SLUG/phase2_discovery/search_results/readmes.jsonl
|
||||
```
|
||||
|
||||
6. **Merge all results** into master database:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py merge \
|
||||
--inputs github-research-output/$SLUG/phase2_discovery/search_results/*.jsonl \
|
||||
--output github-research-output/$SLUG/repo_db.jsonl
|
||||
```
|
||||
|
||||
7. **Write discovery log**: Create `phase2_discovery/discovery_log.md` with search queries used, results per source, total unique repos found.
|
||||
|
||||
### Rate Limits
|
||||
- GitHub search API: 30 requests/minute (authenticated)
|
||||
- Papers With Code API: No strict limit but be respectful (1 req/sec)
|
||||
- Add `--delay 1.0` to batch operations when needed
|
||||
|
||||
### Checkpoint
|
||||
- `repo_db.jsonl` populated with 50-200 repos
|
||||
- `discovery_log.md` with search details
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Filtering
|
||||
|
||||
**Goal**: Score and rank repos, select top 15-30 for deeper analysis.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Enrich metadata** for all repos:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_metadata.py \
|
||||
--input github-research-output/$SLUG/repo_db.jsonl \
|
||||
--output github-research-output/$SLUG/repo_db.jsonl \
|
||||
--delay 0.5
|
||||
```
|
||||
|
||||
2. **Score repos** (quality + activity scores):
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py score \
|
||||
--input github-research-output/$SLUG/repo_db.jsonl \
|
||||
--output github-research-output/$SLUG/repo_db.jsonl
|
||||
```
|
||||
|
||||
3. **LLM relevance scoring**: Read through the top ~50 repos (by quality_score) and assign `relevance_score` (0.0-1.0) based on:
|
||||
- Direct relevance to research topic
|
||||
- Implementation completeness
|
||||
- Code quality signals (from README, description)
|
||||
- Update the relevance scores:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py tag \
|
||||
--input github-research-output/$SLUG/repo_db.jsonl \
|
||||
--ids owner/name --tags "relevance:0.85"
|
||||
```
|
||||
|
||||
4. **Compute composite scores and rank**:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py score \
|
||||
--input github-research-output/$SLUG/repo_db.jsonl \
|
||||
--output github-research-output/$SLUG/repo_db.jsonl
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py rank \
|
||||
--input github-research-output/$SLUG/repo_db.jsonl \
|
||||
--output github-research-output/$SLUG/phase3_filtering/ranked_repos.jsonl \
|
||||
--by composite_score
|
||||
```
|
||||
|
||||
5. **Select top repos**: Filter to top 15-30:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/repo_db.py filter \
|
||||
--input github-research-output/$SLUG/phase3_filtering/ranked_repos.jsonl \
|
||||
--output github-research-output/$SLUG/phase3_filtering/ranked_repos.jsonl \
|
||||
--max-repos 30 --not-archived
|
||||
```
|
||||
|
||||
6. **Write filtering report**: Create `phase3_filtering/filtering_report.md`:
|
||||
- Stats before/after filtering
|
||||
- Score distributions
|
||||
- Top 30 repos with scores and rationale
|
||||
|
||||
### Scoring Formula
|
||||
```
|
||||
activity_score = sigmoid((days_since_push < 90) * 0.4 + has_recent_commits * 0.3 + open_issues_ratio * 0.3)
|
||||
quality_score = normalize(log(stars+1) * 0.3 + log(forks+1) * 0.2 + has_license * 0.15 + has_readme * 0.15 + not_archived * 0.2)
|
||||
composite_score = relevance * 0.4 + quality * 0.35 + activity * 0.25
|
||||
```
|
||||
|
||||
### Checkpoint
|
||||
- `ranked_repos.jsonl` with 15-30 repos
|
||||
- `filtering_report.md` with scoring details
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Deep Dive
|
||||
|
||||
**Goal**: Clone and deeply analyze the top 8-15 repos.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Select repos for deep dive**: Take top 8-15 from ranked list.
|
||||
|
||||
2. **Clone each repo** (shallow):
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/clone_repo.py \
|
||||
--repo owner/name \
|
||||
--output-dir github-research-output/$SLUG/phase4_deep_dive/repos/
|
||||
```
|
||||
|
||||
3. **Analyze structure** for each cloned repo:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/analyze_repo_structure.py \
|
||||
--repo-dir github-research-output/$SLUG/phase4_deep_dive/repos/name/ \
|
||||
--output github-research-output/$SLUG/phase4_deep_dive/analyses/name_structure.json
|
||||
```
|
||||
|
||||
4. **Extract dependencies**:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/extract_dependencies.py \
|
||||
--repo-dir github-research-output/$SLUG/phase4_deep_dive/repos/name/ \
|
||||
--output github-research-output/$SLUG/phase4_deep_dive/analyses/name_deps.json
|
||||
```
|
||||
|
||||
5. **Find implementations**: Search for key algorithms/concepts from research:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/find_implementations.py \
|
||||
--repo-dir github-research-output/$SLUG/phase4_deep_dive/repos/name/ \
|
||||
--patterns "class Transformer" "def forward" "attention" \
|
||||
--output github-research-output/$SLUG/phase4_deep_dive/analyses/name_impls.jsonl
|
||||
```
|
||||
|
||||
6. **Deep code reading**: For each repo, READ the key source files identified by structure analysis. Write a per-repo analysis in `phase4_deep_dive/analyses/{name}_analysis.md`:
|
||||
- Architecture overview
|
||||
- Key algorithms implemented
|
||||
- Code quality assessment
|
||||
- API / interface design
|
||||
- Dependencies and requirements
|
||||
- Strengths and limitations
|
||||
- Reusability assessment (how easy to extract components)
|
||||
|
||||
7. **Write deep dive summary**: `phase4_deep_dive/deep_dive_summary.md`
|
||||
|
||||
### IMPORTANT: Actually Read Code
|
||||
Do NOT just summarize READMEs. You must:
|
||||
- Read the main source files (entry points, core modules)
|
||||
- Understand the actual implementation approach
|
||||
- Identify specific functions/classes that implement research concepts
|
||||
- Note code patterns, design decisions, and trade-offs
|
||||
|
||||
### Checkpoint
|
||||
- Repos cloned in `repos/`
|
||||
- Per-repo analysis files in `analyses/`
|
||||
- `deep_dive_summary.md` written
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Analysis
|
||||
|
||||
**Goal**: Cross-repo comparison and technique-to-code mapping.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Generate comparison matrix**:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/compare_repos.py \
|
||||
--input github-research-output/$SLUG/phase4_deep_dive/analyses/ \
|
||||
--output github-research-output/$SLUG/phase5_analysis/comparison.json
|
||||
```
|
||||
|
||||
2. **Write comparison matrix**: Create `phase5_analysis/comparison_matrix.md`:
|
||||
- Table comparing repos across dimensions (language, LOC, stars, framework, license, tests)
|
||||
- Dependency overlap analysis
|
||||
- Strengths/weaknesses per repo
|
||||
|
||||
3. **Write technique map**: Create `phase5_analysis/technique_map.md`:
|
||||
- Map each paper concept / research technique → specific repo + file + function
|
||||
- Identify gaps (techniques with no implementation found)
|
||||
- Note alternative implementations of the same concept
|
||||
|
||||
4. **Write analysis report**: `phase5_analysis/analysis_report.md`:
|
||||
- Executive summary of findings
|
||||
- Key insights from code analysis
|
||||
- Recommendations for which repos to use for which purposes
|
||||
|
||||
### Checkpoint
|
||||
- `comparison_matrix.md` with repo comparison table
|
||||
- `technique_map.md` mapping concepts to code
|
||||
- `analysis_report.md` with findings
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Blueprint
|
||||
|
||||
**Goal**: Produce an actionable integration and reuse plan.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Write integration plan**: `phase6_blueprint/integration_plan.md`:
|
||||
- Recommended architecture for combining repos
|
||||
- Step-by-step integration approach
|
||||
- Dependency resolution strategy
|
||||
- Potential conflicts and how to resolve them
|
||||
|
||||
2. **Write reuse catalog**: `phase6_blueprint/reuse_catalog.md`:
|
||||
- For each reusable component: source repo, file path, function/class, what it does, how to extract it
|
||||
- License compatibility matrix
|
||||
- Effort estimates (easy/medium/hard to integrate)
|
||||
|
||||
3. **Compile final report**:
|
||||
```bash
|
||||
python ~/.claude/skills/github-research/scripts/compile_github_report.py \
|
||||
--topic-dir github-research-output/$SLUG/
|
||||
```
|
||||
|
||||
4. **Write blueprint summary**: `phase6_blueprint/blueprint_summary.md`:
|
||||
- One-page executive summary
|
||||
- Top 5 repos and why
|
||||
- Recommended next steps
|
||||
|
||||
### Checkpoint
|
||||
- `integration_plan.md` complete
|
||||
- `reuse_catalog.md` with component catalog
|
||||
- `final_report.md` compiled
|
||||
- `blueprint_summary.md` as executive summary
|
||||
|
||||
---
|
||||
|
||||
## Quality Conventions
|
||||
|
||||
1. **Repos are ranked by composite score**: `relevance × 0.4 + quality × 0.35 + activity × 0.25`
|
||||
2. **Deep dive requires reading actual code**, not just READMEs
|
||||
3. **Integration blueprint must map paper concepts → specific code files/functions**
|
||||
4. **Incremental saves**: Each phase writes to disk immediately
|
||||
5. **Checkpoint recovery**: Can resume from any phase by checking what outputs exist
|
||||
6. **All scripts are stdlib-only Python** — no pip installs needed
|
||||
7. **`gh` CLI is required** for GitHub API access (must be authenticated)
|
||||
8. **Deduplication** by `repo_id` (owner/name) across all searches
|
||||
9. **Rate limit awareness**: Respect GitHub search API limits (30 req/min)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If `gh` is not installed: warn user and provide installation instructions
|
||||
- If a repo is archived/deleted: skip gracefully, note in log
|
||||
- If clone fails: skip, note in log, continue with remaining repos
|
||||
- If Papers With Code API is down: skip, rely on GitHub search only
|
||||
- Always write partial progress to disk so work is not lost
|
||||
|
||||
## References
|
||||
|
||||
- See `references/phase-guide.md` for detailed phase execution guidance
|
||||
- Deep-research skill: `~/.claude/skills/deep-research/SKILL.md`
|
||||
- Paper database pattern: `~/.claude/skills/deep-research/scripts/paper_db.py`
|
||||
@@ -0,0 +1,215 @@
|
||||
# GitHub Research — Phase Guide
|
||||
|
||||
Detailed methodology reference for the github-research skill.
|
||||
|
||||
## Phase 1: Intake — Detailed Guide
|
||||
|
||||
### Purpose
|
||||
Extract structured information from deep-research output to seed GitHub discovery.
|
||||
|
||||
### Input Requirements
|
||||
- Deep-research output directory containing:
|
||||
- `paper_db.jsonl` (required)
|
||||
- `phase4_code/code_repos.md` (optional but valuable)
|
||||
- `phase5_synthesis/synthesis.md` (optional)
|
||||
- `phase6_report/report.md` (optional)
|
||||
|
||||
### Keyword Extraction Strategy
|
||||
- **Primary keywords**: From paper titles — extract 2-3 word technical phrases
|
||||
- **Secondary keywords**: From paper tags in paper_db.jsonl
|
||||
- **Tertiary keywords**: Method names, algorithm names, architecture names from synthesis
|
||||
- **Author-based**: Search for prolific authors' GitHub profiles
|
||||
|
||||
### Expected Output
|
||||
- 5-20 GitHub URLs directly from papers
|
||||
- 10-30 search keywords of varying specificity
|
||||
- Clear mapping: which papers mention which repos
|
||||
|
||||
### Edge Cases
|
||||
- No code_repos.md: rely entirely on paper_db.jsonl keywords
|
||||
- No paper_db.jsonl: ask user for manual topic keywords
|
||||
- Non-English papers: extract English technical terms only
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Discovery — Detailed Guide
|
||||
|
||||
### Search Strategy Matrix
|
||||
| Strategy | Query Pattern | Sort | When to Use |
|
||||
|----------|--------------|------|-------------|
|
||||
| Broad topic | "multi-agent LLM framework" | stars | Always — establishes landscape |
|
||||
| Paper title | "{exact paper title}" | best-match | For each key paper |
|
||||
| Method name | "{algorithm name} implementation" | stars | For specific techniques |
|
||||
| Author search | "{author name}" + topic | updated | For prolific researchers |
|
||||
| Code pattern | "class {ClassName}" | - | For specific implementations |
|
||||
| Language-specific | topic + language:python | stars | When language matters |
|
||||
| Awesome list | "awesome-{topic}" | stars | To find curated lists |
|
||||
|
||||
### Rate Limiting
|
||||
- GitHub search API: 30 requests/minute (unauthenticated), 10 requests/minute (code search)
|
||||
- Papers With Code API: ~60 requests/minute
|
||||
- Always set GITHUB_TOKEN for higher limits (5000 req/hr)
|
||||
|
||||
### Deduplication
|
||||
- Primary key: `repo_id` (owner/name, case-insensitive)
|
||||
- When merging duplicates: keep record with more populated fields; merge paper_ids lists
|
||||
|
||||
### Target Numbers
|
||||
- Aim for 50-200 unique repos before filtering
|
||||
- Use at least 5 different search queries
|
||||
- Check Papers With Code for all papers with arxiv_ids
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Filtering — Detailed Guide
|
||||
|
||||
### Scoring Deep Dive
|
||||
|
||||
**Activity Score** (0-1):
|
||||
- Days since last push: <30d -> 0.9-1.0, 30-90d -> 0.6-0.8, 90-365d -> 0.3-0.5, >365d -> 0.0-0.2
|
||||
- Frequency weight: pushed_at recency matters most
|
||||
|
||||
**Quality Score** (0-1):
|
||||
- Stars (log-scaled, 30% weight): log(stars+1) normalized across set
|
||||
- Forks (log-scaled, 20%): log(forks+1) normalized
|
||||
- Has license (15%): any recognized license = 1.0
|
||||
- Not archived (20%): archived repos get 0
|
||||
- Has README (15%): non-empty readme_excerpt = 1.0
|
||||
|
||||
**Relevance Score** (0-1, manually assigned):
|
||||
- 0.9-1.0: Direct implementation of a paper in the literature review
|
||||
- 0.7-0.89: Closely related technique or framework
|
||||
- 0.5-0.69: Related but tangential (e.g., general ML framework used by papers)
|
||||
- 0.3-0.49: Loosely related (e.g., same domain, different approach)
|
||||
- 0.0-0.29: Unlikely useful
|
||||
|
||||
**Composite**: relevance x 0.4 + quality x 0.35 + activity x 0.25
|
||||
|
||||
### Selection Criteria
|
||||
- Always include: repos directly linked to papers
|
||||
- Prefer: repos with tests, documentation, active maintenance
|
||||
- Diversity: ensure mix of approaches, not just top-starred
|
||||
- Minimum: 15 repos; Maximum: 30 repos
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Deep Dive — Detailed Guide
|
||||
|
||||
### What "Deep Dive" Means
|
||||
This is NOT a README scan. You must:
|
||||
1. Clone the repo (shallow)
|
||||
2. Read the directory structure
|
||||
3. Open and read key source files (model definitions, training loops, core algorithms)
|
||||
4. Trace the execution flow from entry point to core logic
|
||||
5. Evaluate code quality, documentation, test coverage
|
||||
|
||||
### Per-Repo Analysis Template
|
||||
```markdown
|
||||
# {owner/name} — Deep Dive Analysis
|
||||
|
||||
## Overview
|
||||
- **Purpose**: {one-sentence}
|
||||
- **Stars**: {N} | **Language**: {lang} | **License**: {license}
|
||||
- **Last active**: {date} | **Composite score**: {score}
|
||||
|
||||
## Architecture
|
||||
- Entry point: `{file}` -> calls `{function}` -> uses `{module}`
|
||||
- Core modules: {list key files with purposes}
|
||||
- Data flow: {how data moves through the system}
|
||||
|
||||
## Key Algorithms
|
||||
- **{Algorithm 1}**: Implemented in `{file}:{lines}`, function `{name}`
|
||||
- Matches paper: {yes/no/partially} — {details}
|
||||
- **{Algorithm 2}**: ...
|
||||
|
||||
## Code Quality
|
||||
- Documentation: {poor/fair/good/excellent}
|
||||
- Test coverage: {none/minimal/moderate/comprehensive}
|
||||
- Code style: {consistent/inconsistent}, {patterns used}
|
||||
- Error handling: {minimal/adequate/thorough}
|
||||
|
||||
## Dependencies
|
||||
- Core: {list key deps}
|
||||
- ML framework: {pytorch/tensorflow/jax/none}
|
||||
- Hardware: {CPU only / GPU required / TPU supported}
|
||||
|
||||
## Reusability Assessment
|
||||
- Ease of extraction: {easy/moderate/difficult}
|
||||
- Tight couplings: {what's hard to separate}
|
||||
- API surface: {clean/messy}
|
||||
- Recommended components: {what to reuse}
|
||||
|
||||
## Limitations
|
||||
- {limitation 1}
|
||||
- {limitation 2}
|
||||
```
|
||||
|
||||
### Prioritization
|
||||
- Read model/algorithm files first (highest value)
|
||||
- Then training/evaluation scripts
|
||||
- Then configuration and entry points
|
||||
- Skip: CI config, linting config, CHANGELOG
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Analysis — Detailed Guide
|
||||
|
||||
### Comparison Matrix Dimensions
|
||||
Build a table comparing all deep-dived repos across:
|
||||
1. Primary language & ML framework
|
||||
2. Lines of code & file count
|
||||
3. Code quality rating (1-5)
|
||||
4. Paper fidelity (how closely it matches the paper)
|
||||
5. Reusability rating (1-5)
|
||||
6. Activity status (active/maintained/stale/archived)
|
||||
7. Dependency count & overlap
|
||||
8. License restrictiveness (permissive/copyleft/unknown)
|
||||
9. Hardware requirements (CPU/GPU/TPU)
|
||||
|
||||
### Technique Map Construction
|
||||
For each significant concept in the deep-research papers:
|
||||
1. Identify the concept (e.g., "multi-head attention", "reward shaping")
|
||||
2. Find implementations in analyzed repos
|
||||
3. Note the specific file, class/function, and line numbers
|
||||
4. Rate fidelity: faithful / modified / inspired-by / missing
|
||||
5. Note any improvements or deviations from the paper
|
||||
|
||||
### Gap Analysis
|
||||
Identify:
|
||||
- Paper concepts with NO implementation found
|
||||
- Implementations that deviate significantly from papers
|
||||
- Missing components needed for a complete system
|
||||
- Opportunities for novel contributions
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Blueprint — Detailed Guide
|
||||
|
||||
### Integration Plan Structure
|
||||
1. **Objective**: What system are we building?
|
||||
2. **Recommended Stack**: Best combination of repos
|
||||
3. **Architecture Diagram** (text-based): How repos fit together
|
||||
4. **Step-by-step Plan**:
|
||||
- Step 1: Start with {repo} as base
|
||||
- Step 2: Extract {component} from {repo}
|
||||
- Step 3: Adapt {module} to work with base
|
||||
- ...
|
||||
5. **Risk Assessment**: License conflicts, version incompatibilities, maintenance risk
|
||||
6. **Estimated Complexity**: Per-step effort estimate (trivial/moderate/significant)
|
||||
|
||||
### Reuse Catalog Format
|
||||
For each extractable component:
|
||||
- Source repo and file path
|
||||
- What it does
|
||||
- Dependencies required
|
||||
- How to extract (copy files, install deps, adapt imports)
|
||||
- API surface (key classes/functions, input/output types)
|
||||
- Caveats and gotchas
|
||||
|
||||
### Quality Checklist
|
||||
Before finalizing:
|
||||
- [ ] Every paper concept has a code mapping (or explicit "not found")
|
||||
- [ ] License compatibility checked for recommended stack
|
||||
- [ ] Dependency conflicts identified between repos
|
||||
- [ ] At least one "quick start" integration path identified
|
||||
- [ ] Gaps clearly documented with suggested approaches
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Map repo file tree, identify key files, and compute LOC stats.
|
||||
|
||||
Usage:
|
||||
python analyze_repo_structure.py --repo-dir /tmp/repos/myrepo --output analysis.json [--repo-id owner/name]
|
||||
|
||||
Writes analysis JSON to the specified output file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Directories to skip during traversal
|
||||
SKIP_DIRS = {
|
||||
".git", "node_modules", "__pycache__", ".venv", "venv",
|
||||
".tox", ".eggs", "dist", "build",
|
||||
}
|
||||
|
||||
# File extension to language mapping
|
||||
EXT_TO_LANG = {
|
||||
".py": "Python",
|
||||
".js": "JavaScript",
|
||||
".ts": "TypeScript",
|
||||
".rs": "Rust",
|
||||
".go": "Go",
|
||||
".java": "Java",
|
||||
".cpp": "C++",
|
||||
".cc": "C++",
|
||||
".cxx": "C++",
|
||||
".c": "C",
|
||||
".rb": "Ruby",
|
||||
".sh": "Shell",
|
||||
".yaml": "YAML",
|
||||
".yml": "YAML",
|
||||
".json": "JSON",
|
||||
".md": "Markdown",
|
||||
}
|
||||
|
||||
# Known config file names
|
||||
CONFIG_FILES = {
|
||||
"pyproject.toml", "setup.py", "setup.cfg", "package.json",
|
||||
"Cargo.toml", "go.mod", "Makefile", "Dockerfile",
|
||||
"docker-compose.yml", "docker-compose.yaml",
|
||||
".env.example", "tox.ini",
|
||||
}
|
||||
|
||||
# Known entry-point file names
|
||||
ENTRY_POINT_NAMES = {"main.py", "app.py", "run.py", "train.py", "setup.py", "Makefile", "Dockerfile"}
|
||||
|
||||
|
||||
def is_egg_info_dir(name: str) -> bool:
|
||||
return name.endswith(".egg-info")
|
||||
|
||||
|
||||
def count_loc(filepath: str) -> int:
|
||||
"""Count non-empty lines in a text file."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
return sum(1 for line in f if line.strip())
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return 0
|
||||
|
||||
|
||||
def has_shebang(filepath: str) -> bool:
|
||||
"""Check if file starts with a shebang line."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
first_line = f.readline()
|
||||
return first_line.startswith("#!")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
|
||||
|
||||
def has_main_guard(filepath: str) -> bool:
|
||||
"""Check if a Python file has `if __name__` guard."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if re.search(r'if\s+__name__\s*==\s*["\']__main__["\']', line):
|
||||
return True
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def should_skip_dir(name: str) -> bool:
|
||||
return name in SKIP_DIRS or is_egg_info_dir(name)
|
||||
|
||||
|
||||
def build_tree(repo_dir: str, max_lines: int = 200) -> str:
|
||||
"""Generate an indented file tree string, truncated to max_lines."""
|
||||
lines = []
|
||||
base = os.path.basename(os.path.abspath(repo_dir))
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(repo_dir):
|
||||
# Filter out skipped dirs in-place
|
||||
dirnames[:] = sorted([d for d in dirnames if not should_skip_dir(d)])
|
||||
filenames = sorted(filenames)
|
||||
|
||||
rel = os.path.relpath(dirpath, repo_dir)
|
||||
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
||||
indent = " " * depth
|
||||
|
||||
if rel == ".":
|
||||
lines.append(f"{base}/")
|
||||
else:
|
||||
lines.append(f"{indent}{os.path.basename(dirpath)}/")
|
||||
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
|
||||
file_indent = " " * (depth + 1)
|
||||
for fname in filenames:
|
||||
lines.append(f"{file_indent}{fname}")
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
|
||||
if len(lines) >= max_lines:
|
||||
lines.append(" ... (truncated)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def analyze(repo_dir: str, repo_id: str) -> dict:
|
||||
"""Perform full repo structure analysis."""
|
||||
total_files = 0
|
||||
total_loc = 0
|
||||
languages: dict[str, int] = {}
|
||||
key_files: list[str] = []
|
||||
entry_points: list[str] = []
|
||||
config_files: list[str] = []
|
||||
test_files: list[str] = []
|
||||
doc_files: list[str] = []
|
||||
file_loc: list[tuple[str, int]] = []
|
||||
|
||||
print(f"[analyze] Scanning {repo_dir} ...", file=sys.stderr)
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(repo_dir):
|
||||
dirnames[:] = [d for d in dirnames if not should_skip_dir(d)]
|
||||
|
||||
rel_dir = os.path.relpath(dirpath, repo_dir)
|
||||
|
||||
for fname in filenames:
|
||||
filepath = os.path.join(dirpath, fname)
|
||||
rel_path = os.path.join(rel_dir, fname) if rel_dir != "." else fname
|
||||
# Normalize path separators
|
||||
rel_path = rel_path.replace(os.sep, "/")
|
||||
|
||||
total_files += 1
|
||||
|
||||
_, ext = os.path.splitext(fname)
|
||||
ext_lower = ext.lower()
|
||||
|
||||
# LOC counting for known languages
|
||||
lang = EXT_TO_LANG.get(ext_lower)
|
||||
loc = 0
|
||||
if lang:
|
||||
loc = count_loc(filepath)
|
||||
languages[lang] = languages.get(lang, 0) + loc
|
||||
total_loc += loc
|
||||
file_loc.append((rel_path, loc))
|
||||
|
||||
# Entry points detection
|
||||
if fname in ENTRY_POINT_NAMES:
|
||||
entry_points.append(rel_path)
|
||||
elif ext_lower == ".py" and has_main_guard(filepath):
|
||||
entry_points.append(rel_path)
|
||||
elif has_shebang(filepath):
|
||||
entry_points.append(rel_path)
|
||||
|
||||
# Config files
|
||||
if fname in CONFIG_FILES:
|
||||
config_files.append(rel_path)
|
||||
|
||||
# Test files
|
||||
if (fname.startswith("test_") or fname.endswith(("_test.py", "_test.js", "_test.ts", "_test.go"))
|
||||
or "/tests/" in f"/{rel_path}/" or "/test/" in f"/{rel_path}/"
|
||||
or "/spec/" in f"/{rel_path}/"):
|
||||
test_files.append(rel_path)
|
||||
|
||||
# Doc files
|
||||
if ext_lower == ".md" or "/docs/" in f"/{rel_path}/" or "/doc/" in f"/{rel_path}/":
|
||||
doc_files.append(rel_path)
|
||||
|
||||
# Sort files by LOC descending, take top 10
|
||||
file_loc.sort(key=lambda x: x[1], reverse=True)
|
||||
file_size_top10 = file_loc[:10]
|
||||
|
||||
# Key files = top LOC files + entry points
|
||||
key_set = set()
|
||||
for path, _ in file_size_top10:
|
||||
key_set.add(path)
|
||||
for path in entry_points:
|
||||
key_set.add(path)
|
||||
key_files = sorted(key_set)
|
||||
|
||||
# Sort languages by LOC descending
|
||||
languages = dict(sorted(languages.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
tree = build_tree(repo_dir)
|
||||
|
||||
print(f"[analyze] Files: {total_files}, LOC: {total_loc}, Languages: {len(languages)}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"total_files": total_files,
|
||||
"total_loc": total_loc,
|
||||
"languages": languages,
|
||||
"key_files": key_files,
|
||||
"entry_points": sorted(set(entry_points)),
|
||||
"config_files": sorted(set(config_files)),
|
||||
"test_files": sorted(set(test_files)),
|
||||
"doc_files": sorted(set(doc_files)),
|
||||
"tree": tree,
|
||||
"file_size_top10": file_size_top10,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Analyze repository structure, files, and LOC stats.")
|
||||
parser.add_argument("--repo-dir", required=True, help="Path to the cloned repository")
|
||||
parser.add_argument("--output", required=True, help="Output JSON file path")
|
||||
parser.add_argument("--repo-id", default="unknown/unknown", help="Repository identifier (owner/name)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.repo_dir):
|
||||
print(f"Error: {args.repo_dir} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = analyze(args.repo_dir, args.repo_id)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"[analyze] Output written to {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shallow-clone GitHub repos for local code analysis.
|
||||
|
||||
Usage:
|
||||
python clone_repo.py --repo owner/name --output-dir /tmp/repos [--depth 1] [--branch main]
|
||||
|
||||
Outputs JSON to stdout with clone result; stats to stderr.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def get_dir_size(path: str) -> int:
|
||||
"""Walk directory and sum all file sizes in bytes."""
|
||||
total = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
try:
|
||||
total += os.path.getsize(fp)
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def clone_repo(repo: str, output_dir: str, depth: int, branch: str | None) -> dict:
|
||||
"""Clone a GitHub repo and return result metadata."""
|
||||
parts = repo.split("/")
|
||||
if len(parts) != 2:
|
||||
return {
|
||||
"repo_id": repo,
|
||||
"local_path": None,
|
||||
"clone_success": False,
|
||||
"error": f"Invalid repo format '{repo}', expected 'owner/name'",
|
||||
}
|
||||
|
||||
owner, name = parts
|
||||
url = f"https://github.com/{owner}/{name}.git"
|
||||
target = os.path.join(output_dir, name)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Build clone command
|
||||
cmd = ["git", "clone", f"--depth={depth}", "--single-branch"]
|
||||
if branch:
|
||||
cmd += ["--branch", branch]
|
||||
cmd += [url, target]
|
||||
|
||||
print(f"[clone] Running: {' '.join(cmd)}", file=sys.stderr)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
# If branch-specific clone fails, retry without --branch
|
||||
if result.returncode != 0 and branch:
|
||||
print(f"[clone] Branch '{branch}' failed, retrying with default branch...", file=sys.stderr)
|
||||
cmd_retry = ["git", "clone", f"--depth={depth}", "--single-branch", url, target]
|
||||
result = subprocess.run(cmd_retry, capture_output=True, text=True)
|
||||
|
||||
# Validate clone
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"repo_id": repo,
|
||||
"local_path": target,
|
||||
"clone_success": False,
|
||||
"error": result.stderr.strip(),
|
||||
}
|
||||
|
||||
if not os.path.isdir(target) or not os.listdir(target):
|
||||
return {
|
||||
"repo_id": repo,
|
||||
"local_path": target,
|
||||
"clone_success": False,
|
||||
"error": "Clone directory is empty or missing",
|
||||
}
|
||||
|
||||
# Compute clone size
|
||||
size_bytes = get_dir_size(target)
|
||||
size_mb = size_bytes / (1024 * 1024)
|
||||
file_count = sum(len(files) for _, _, files in os.walk(target))
|
||||
|
||||
print(f"[clone] Success: {target}", file=sys.stderr)
|
||||
print(f"[clone] Size: {size_mb:.1f} MB, Files: {file_count}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"repo_id": repo,
|
||||
"local_path": os.path.abspath(target),
|
||||
"clone_success": True,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Shallow-clone a GitHub repo for local analysis.")
|
||||
parser.add_argument("--repo", required=True, help="Repository in owner/name format")
|
||||
parser.add_argument("--output-dir", required=True, help="Parent directory for the clone")
|
||||
parser.add_argument("--depth", type=int, default=1, help="Clone depth (default: 1)")
|
||||
parser.add_argument("--branch", default=None, help="Branch to clone (default: repo default branch)")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = clone_repo(args.repo, args.output_dir, args.depth, args.branch)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate comparison matrix across analyzed repositories.
|
||||
|
||||
Reads analysis JSON files and dependency files to produce a structured comparison.
|
||||
|
||||
Usage:
|
||||
python compare_repos.py --input phase4_deep_dive/analyses/ --output comparison.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from itertools import combinations
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I/O helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_json(path: str) -> dict | None:
|
||||
"""Load a JSON file, returning None on failure."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" Warning: cannot load {path}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def find_analysis_files(analyses_dir: str) -> dict[str, dict[str, dict | None]]:
|
||||
"""Discover *_structure.json and *_deps.json files, grouped by repo name.
|
||||
|
||||
Returns {repo_name: {"structure": <dict|None>, "deps": <dict|None>}}.
|
||||
"""
|
||||
repos: dict[str, dict[str, dict | None]] = {}
|
||||
|
||||
for fname in sorted(os.listdir(analyses_dir)):
|
||||
fpath = os.path.join(analyses_dir, fname)
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
|
||||
if fname.endswith("_structure.json"):
|
||||
name = fname.removesuffix("_structure.json")
|
||||
repos.setdefault(name, {"structure": None, "deps": None})
|
||||
repos[name]["structure"] = load_json(fpath)
|
||||
elif fname.endswith("_deps.json"):
|
||||
name = fname.removesuffix("_deps.json")
|
||||
repos.setdefault(name, {"structure": None, "deps": None})
|
||||
repos[name]["deps"] = load_json(fpath)
|
||||
|
||||
return repos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dimension extractors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get(data: dict | None, *keys: str, default: object = None) -> object:
|
||||
"""Try multiple keys on a dict, returning the first truthy value."""
|
||||
if data is None:
|
||||
return default
|
||||
for key in keys:
|
||||
val = data.get(key)
|
||||
if val is not None and val != "" and val != [] and val != {}:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def extract_repo_id(
|
||||
structure: dict | None, deps: dict | None, file_key: str,
|
||||
) -> str:
|
||||
"""Best-effort extraction of owner/name repo_id."""
|
||||
rid = _get(structure, "repo_id", "repo")
|
||||
if rid:
|
||||
return str(rid)
|
||||
rid = _get(deps, "repo_id", "repo")
|
||||
if rid:
|
||||
return str(rid)
|
||||
return file_key
|
||||
|
||||
|
||||
def extract_primary_language(structure: dict | None) -> str:
|
||||
"""Primary language from structure analysis."""
|
||||
lang = _get(structure, "primary_language", "language")
|
||||
if lang and isinstance(lang, str):
|
||||
return lang
|
||||
# Fall back: pick language with highest LOC
|
||||
langs = _get(structure, "languages", "language_stats", default={})
|
||||
if isinstance(langs, dict) and langs:
|
||||
return max(langs, key=lambda k: langs[k] if isinstance(langs[k], (int, float)) else 0)
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def extract_total_loc(structure: dict | None) -> int:
|
||||
"""Total lines of code."""
|
||||
loc = _get(structure, "total_loc", "loc", default=0)
|
||||
if isinstance(loc, (int, float)):
|
||||
return int(loc)
|
||||
# Sum across languages
|
||||
langs = _get(structure, "languages", "language_stats", default={})
|
||||
if isinstance(langs, dict):
|
||||
return sum(v for v in langs.values() if isinstance(v, (int, float)))
|
||||
return 0
|
||||
|
||||
|
||||
def extract_total_files(structure: dict | None) -> int:
|
||||
"""Total file count."""
|
||||
count = _get(structure, "total_files", "file_count", default=0)
|
||||
if isinstance(count, (int, float)):
|
||||
return int(count)
|
||||
files = _get(structure, "files", "file_tree", default=[])
|
||||
if isinstance(files, list):
|
||||
return len(files)
|
||||
return 0
|
||||
|
||||
|
||||
def extract_stars(structure: dict | None) -> int:
|
||||
"""Star count."""
|
||||
return int(_get(structure, "stars", default=0) or 0)
|
||||
|
||||
|
||||
def extract_ml_frameworks(deps: dict | None) -> list[str]:
|
||||
"""Detect ML frameworks from dependency data."""
|
||||
if deps is None:
|
||||
return _get(deps, "ml_frameworks", default=[]) or [] # type: ignore[return-value]
|
||||
|
||||
# If the deps file already has ml_frameworks, use it
|
||||
existing = deps.get("ml_frameworks")
|
||||
if existing and isinstance(existing, list):
|
||||
return existing
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def extract_has_tests(structure: dict | None) -> bool:
|
||||
"""Whether the repo has test files."""
|
||||
if structure is None:
|
||||
return False
|
||||
# Direct field
|
||||
test_files = _get(structure, "test_files", default=[])
|
||||
if isinstance(test_files, list) and test_files:
|
||||
return True
|
||||
# Check has_tests flag
|
||||
if structure.get("has_tests"):
|
||||
return True
|
||||
# Scan file list for test indicators
|
||||
files = _get(structure, "files", "file_tree", default=[])
|
||||
if isinstance(files, list):
|
||||
for f in files:
|
||||
name = f if isinstance(f, str) else (
|
||||
f.get("path", "") if isinstance(f, dict) else ""
|
||||
)
|
||||
name_lower = name.lower()
|
||||
if ("/test/" in name_lower or "/tests/" in name_lower
|
||||
or name_lower.startswith("test") or "test_" in name_lower):
|
||||
return True
|
||||
# Check key directories
|
||||
key_dirs = _get(structure, "key_dirs", "directories", default=[])
|
||||
if isinstance(key_dirs, list):
|
||||
for d in key_dirs:
|
||||
d_name = d if isinstance(d, str) else (
|
||||
d.get("name", "") if isinstance(d, dict) else ""
|
||||
)
|
||||
if d_name.lower() in ("test", "tests", "testing", "test_suite"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_has_docker(structure: dict | None) -> bool:
|
||||
"""Whether the repo has Docker configuration."""
|
||||
if structure is None:
|
||||
return False
|
||||
config_files = _get(structure, "config_files", default=[])
|
||||
if isinstance(config_files, list):
|
||||
for f in config_files:
|
||||
name = f if isinstance(f, str) else (
|
||||
f.get("path", f.get("name", "")) if isinstance(f, dict) else ""
|
||||
)
|
||||
name_lower = name.lower()
|
||||
if name_lower in ("dockerfile", "docker-compose.yml",
|
||||
"docker-compose.yaml", ".dockerignore"):
|
||||
return True
|
||||
# Also check files list
|
||||
files = _get(structure, "files", "file_tree", default=[])
|
||||
if isinstance(files, list):
|
||||
for f in files:
|
||||
name = f if isinstance(f, str) else (
|
||||
f.get("path", "") if isinstance(f, dict) else ""
|
||||
)
|
||||
basename = os.path.basename(name).lower()
|
||||
if basename in ("dockerfile", "docker-compose.yml",
|
||||
"docker-compose.yaml"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_license(structure: dict | None) -> str:
|
||||
"""License identifier."""
|
||||
return str(_get(structure, "license", default="") or "")
|
||||
|
||||
|
||||
def extract_entry_points(structure: dict | None) -> list[str]:
|
||||
"""Main entry points."""
|
||||
entries = _get(structure, "entry_points", "main_files", default=[])
|
||||
if isinstance(entries, list):
|
||||
return [str(e) for e in entries[:10]]
|
||||
return []
|
||||
|
||||
|
||||
def extract_config_format(structure: dict | None) -> str:
|
||||
"""Dominant config file format."""
|
||||
config_files = _get(structure, "config_files", default=[])
|
||||
if not isinstance(config_files, list):
|
||||
return ""
|
||||
|
||||
format_counts: dict[str, int] = {}
|
||||
ext_map: dict[str, str] = {
|
||||
".yaml": "yaml", ".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".json": "json",
|
||||
".ini": "ini", ".cfg": "ini",
|
||||
}
|
||||
|
||||
for f in config_files:
|
||||
name = f if isinstance(f, str) else (
|
||||
f.get("path", f.get("name", "")) if isinstance(f, dict) else ""
|
||||
)
|
||||
_, ext = os.path.splitext(name.lower())
|
||||
fmt = ext_map.get(ext, "")
|
||||
if fmt:
|
||||
format_counts[fmt] = format_counts.get(fmt, 0) + 1
|
||||
|
||||
if not format_counts:
|
||||
return ""
|
||||
return max(format_counts, key=lambda k: format_counts[k])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency overlap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_all_packages(deps: dict | None) -> set[str]:
|
||||
"""Collect a flat set of normalized package names from a deps record."""
|
||||
if deps is None:
|
||||
return set()
|
||||
|
||||
# Prefer the pre-computed all_packages field
|
||||
all_pkgs = deps.get("all_packages")
|
||||
if isinstance(all_pkgs, list) and all_pkgs:
|
||||
return {p.lower() for p in all_pkgs}
|
||||
|
||||
# Otherwise, gather from per-ecosystem sections
|
||||
names: set[str] = set()
|
||||
|
||||
# Python
|
||||
py = deps.get("python")
|
||||
if isinstance(py, dict):
|
||||
for spec in py.get("requirements", []):
|
||||
if isinstance(spec, str):
|
||||
bare = spec.split(">=")[0].split("<=")[0].split("==")[0]
|
||||
bare = bare.split("~=")[0].split("!=")[0].split("[")[0].strip()
|
||||
if bare:
|
||||
names.add(bare.lower())
|
||||
|
||||
# Node
|
||||
node = deps.get("node")
|
||||
if isinstance(node, dict):
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
val = node.get(key, {})
|
||||
if isinstance(val, dict):
|
||||
names.update(k.lower() for k in val)
|
||||
|
||||
# Rust
|
||||
rust = deps.get("rust")
|
||||
if isinstance(rust, dict):
|
||||
for key in ("dependencies",):
|
||||
val = rust.get(key, {})
|
||||
if isinstance(val, dict):
|
||||
names.update(k.lower() for k in val)
|
||||
|
||||
# Go
|
||||
go = deps.get("go")
|
||||
if isinstance(go, dict):
|
||||
for mod in go.get("modules", []):
|
||||
if isinstance(mod, str):
|
||||
parts = mod.rstrip("/").split("/")
|
||||
names.add(parts[-1].lower())
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def compute_dependency_overlap(
|
||||
repo_packages: dict[str, set[str]],
|
||||
min_shared: int = 3,
|
||||
) -> list[dict]:
|
||||
"""Compute shared packages for each pair of repos.
|
||||
|
||||
Only includes pairs with at least min_shared common packages.
|
||||
"""
|
||||
overlaps: list[dict] = []
|
||||
repo_ids = sorted(repo_packages.keys())
|
||||
for a, b in combinations(repo_ids, 2):
|
||||
shared = sorted(repo_packages[a] & repo_packages[b])
|
||||
if len(shared) >= min_shared:
|
||||
overlaps.append({
|
||||
"repos": [a, b],
|
||||
"shared": shared,
|
||||
})
|
||||
return overlaps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate comparison matrix across analyzed repositories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input", required=True,
|
||||
help="Directory containing *_structure.json and *_deps.json files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", required=True,
|
||||
help="Output comparison JSON file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-db", default=None,
|
||||
help="Optional repo_db.jsonl to enrich with stars/metadata",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.input):
|
||||
print(f"Error: analyses directory not found: {args.input}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load optional repo_db for enrichment (stars, etc.)
|
||||
repo_db_map: dict[str, dict] = {}
|
||||
if args.repo_db and os.path.isfile(args.repo_db):
|
||||
with open(args.repo_db, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
rec = json.loads(line)
|
||||
rid = rec.get("repo_id", "")
|
||||
# Index by both full id and short name
|
||||
repo_db_map[rid] = rec
|
||||
repo_db_map[rid.split("/")[-1]] = rec
|
||||
|
||||
# Discover and load analysis files
|
||||
repos = find_analysis_files(args.input)
|
||||
if not repos:
|
||||
print("Error: no *_structure.json or *_deps.json files found",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found analysis files for {len(repos)} repo(s)", file=sys.stderr)
|
||||
|
||||
# --- Build dimension maps ---
|
||||
repo_ids: list[str] = []
|
||||
dim_primary_language: dict[str, str] = {}
|
||||
dim_total_loc: dict[str, int] = {}
|
||||
dim_total_files: dict[str, int] = {}
|
||||
dim_stars: dict[str, int] = {}
|
||||
dim_ml_framework: dict[str, list[str]] = {}
|
||||
dim_has_tests: dict[str, bool] = {}
|
||||
dim_has_docker: dict[str, bool] = {}
|
||||
dim_license: dict[str, str] = {}
|
||||
dim_entry_points: dict[str, list[str]] = {}
|
||||
dim_config_format: dict[str, str] = {}
|
||||
|
||||
repo_packages: dict[str, set[str]] = {}
|
||||
|
||||
for file_key, data in sorted(repos.items()):
|
||||
structure = data.get("structure")
|
||||
deps = data.get("deps")
|
||||
rid = extract_repo_id(structure, deps, file_key)
|
||||
repo_ids.append(rid)
|
||||
|
||||
dim_primary_language[rid] = extract_primary_language(structure)
|
||||
dim_total_loc[rid] = extract_total_loc(structure)
|
||||
dim_total_files[rid] = extract_total_files(structure)
|
||||
# Stars from structure.json, fallback to repo_db
|
||||
stars = extract_stars(structure)
|
||||
if stars == 0 and rid in repo_db_map:
|
||||
stars = int(repo_db_map[rid].get("stars", 0))
|
||||
if stars == 0:
|
||||
# Try matching by short name
|
||||
short = rid.split("/")[-1]
|
||||
if short in repo_db_map:
|
||||
stars = int(repo_db_map[short].get("stars", 0))
|
||||
dim_stars[rid] = stars
|
||||
dim_ml_framework[rid] = extract_ml_frameworks(deps)
|
||||
dim_has_tests[rid] = extract_has_tests(structure)
|
||||
dim_has_docker[rid] = extract_has_docker(structure)
|
||||
dim_license[rid] = extract_license(structure)
|
||||
dim_entry_points[rid] = extract_entry_points(structure)
|
||||
dim_config_format[rid] = extract_config_format(structure)
|
||||
|
||||
repo_packages[rid] = collect_all_packages(deps)
|
||||
|
||||
# --- Compute pairwise dependency overlap ---
|
||||
dependency_overlap = compute_dependency_overlap(repo_packages)
|
||||
|
||||
# --- Assemble output ---
|
||||
comparison: dict = {
|
||||
"repos": repo_ids,
|
||||
"dimensions": {
|
||||
"primary_language": dim_primary_language,
|
||||
"total_loc": dim_total_loc,
|
||||
"total_files": dim_total_files,
|
||||
"stars": dim_stars,
|
||||
"ml_framework": dim_ml_framework,
|
||||
"has_tests": dim_has_tests,
|
||||
"has_docker": dim_has_docker,
|
||||
"license": dim_license,
|
||||
"entry_points": dim_entry_points,
|
||||
"config_format": dim_config_format,
|
||||
},
|
||||
"dependency_overlap": dependency_overlap,
|
||||
}
|
||||
|
||||
# --- Write output ---
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(comparison, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
# --- Summary to stderr ---
|
||||
n_dims = len(comparison["dimensions"])
|
||||
lang_counts: dict[str, int] = {}
|
||||
for lang in dim_primary_language.values():
|
||||
lang_counts[lang] = lang_counts.get(lang, 0) + 1
|
||||
lang_summary = ", ".join(
|
||||
f"{l}({c})" for l, c in sorted(lang_counts.items(), key=lambda x: -x[1])
|
||||
)
|
||||
|
||||
fw_counts: dict[str, int] = {}
|
||||
for fws in dim_ml_framework.values():
|
||||
for fw in fws:
|
||||
fw_counts[fw] = fw_counts.get(fw, 0) + 1
|
||||
fw_summary = ", ".join(
|
||||
f"{f}({c})" for f, c in sorted(fw_counts.items(), key=lambda x: -x[1])
|
||||
) if fw_counts else "none"
|
||||
|
||||
test_count = sum(1 for v in dim_has_tests.values() if v)
|
||||
docker_count = sum(1 for v in dim_has_docker.values() if v)
|
||||
|
||||
print(f"Compared {len(repo_ids)} repos across {n_dims} dimensions",
|
||||
file=sys.stderr)
|
||||
print(f" Languages: {lang_summary}", file=sys.stderr)
|
||||
print(f" ML frameworks: {fw_summary}", file=sys.stderr)
|
||||
print(f" With tests: {test_count}/{len(repo_ids)}", file=sys.stderr)
|
||||
print(f" With Docker: {docker_count}/{len(repo_ids)}", file=sys.stderr)
|
||||
print(f" Dependency overlaps: {len(dependency_overlap)} pair(s)",
|
||||
file=sys.stderr)
|
||||
print(f" Output: {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble final GitHub research report from all phase outputs.
|
||||
|
||||
Reads repo_db.jsonl and phase outputs to generate statistics and a comprehensive report.
|
||||
|
||||
Usage:
|
||||
python compile_github_report.py --topic-dir ./github-research-output/my-topic
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# -- I/O helpers --------------------------------------------------------------
|
||||
|
||||
def load_jsonl(path: str) -> list[dict]:
|
||||
"""Load records from a JSONL file. Returns empty list if file missing."""
|
||||
records: list[dict] = []
|
||||
if not os.path.isfile(path):
|
||||
return records
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def load_text(path: str) -> str:
|
||||
"""Load a text file. Returns empty string if file missing."""
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def format_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
"""Generate a markdown table from headers and row data."""
|
||||
if not headers:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
lines.append("| " + " | ".join(headers) + " |")
|
||||
# Alignment: right-align columns that look numeric in first data row
|
||||
seps: list[str] = []
|
||||
for i, h in enumerate(headers):
|
||||
if rows and i < len(rows[0]):
|
||||
try:
|
||||
float(str(rows[0][i]).replace(",", ""))
|
||||
seps.append("------:")
|
||||
except (ValueError, IndexError):
|
||||
seps.append("------")
|
||||
else:
|
||||
seps.append("------")
|
||||
lines.append("| " + " | ".join(seps) + " |")
|
||||
for row in rows:
|
||||
cells = [str(c) for c in row]
|
||||
while len(cells) < len(headers):
|
||||
cells.append("")
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def truncate(text: str, max_chars: int = 200) -> str:
|
||||
"""Truncate text with ellipsis if it exceeds max_chars."""
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[: max_chars - 3].rstrip() + "..."
|
||||
|
||||
|
||||
# -- Statistics ---------------------------------------------------------------
|
||||
|
||||
def compute_stats(repos: list[dict]) -> dict:
|
||||
"""Compute all statistics from repo records."""
|
||||
if not repos:
|
||||
return {
|
||||
"total_discovered": 0, "total_filtered": 0, "total_analyzed": 0,
|
||||
"sources": {}, "languages": {}, "stars": {},
|
||||
"activity": {}, "score_buckets": {}, "ml_frameworks": {}, "licenses": {},
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# -- By source --
|
||||
sources: dict[str, int] = {}
|
||||
for rec in repos:
|
||||
src = rec.get("source", "unknown")
|
||||
sources[src] = sources.get(src, 0) + 1
|
||||
|
||||
# -- By language --
|
||||
languages: dict[str, int] = {}
|
||||
for rec in repos:
|
||||
lang = rec.get("language") or "Unknown"
|
||||
languages[lang] = languages.get(lang, 0) + 1
|
||||
languages = dict(sorted(languages.items(), key=lambda x: -x[1]))
|
||||
|
||||
# -- Stars distribution --
|
||||
star_values = [rec.get("stars", 0) or 0 for rec in repos]
|
||||
star_values_sorted = sorted(star_values)
|
||||
n = len(star_values_sorted)
|
||||
stars_info: dict[str, float | int] = {
|
||||
"min": star_values_sorted[0],
|
||||
"max": star_values_sorted[-1],
|
||||
"median": star_values_sorted[n // 2] if n % 2 else
|
||||
(star_values_sorted[n // 2 - 1] + star_values_sorted[n // 2]) / 2,
|
||||
"mean": round(sum(star_values) / n, 1),
|
||||
"p25": star_values_sorted[max(n // 4 - 1, 0)],
|
||||
"p75": star_values_sorted[min(3 * n // 4, n - 1)],
|
||||
}
|
||||
|
||||
# -- Activity --
|
||||
activity: dict[str, int] = {"last_90d": 0, "last_year": 0, "older": 0}
|
||||
for rec in repos:
|
||||
pushed_str = rec.get("pushed_at") or rec.get("updated_at") or ""
|
||||
if not pushed_str:
|
||||
activity["older"] += 1
|
||||
continue
|
||||
try:
|
||||
pushed = datetime.fromisoformat(pushed_str.replace("Z", "+00:00"))
|
||||
days = (now - pushed).days
|
||||
if days <= 90:
|
||||
activity["last_90d"] += 1
|
||||
elif days <= 365:
|
||||
activity["last_year"] += 1
|
||||
else:
|
||||
activity["older"] += 1
|
||||
except (ValueError, TypeError):
|
||||
activity["older"] += 1
|
||||
|
||||
# -- Composite score histogram --
|
||||
score_buckets: dict[str, int] = {
|
||||
"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0, "0.6-0.8": 0, "0.8-1.0": 0,
|
||||
}
|
||||
for rec in repos:
|
||||
cs = rec.get("composite_score", 0.0) or 0.0
|
||||
if cs >= 0.8:
|
||||
score_buckets["0.8-1.0"] += 1
|
||||
elif cs >= 0.6:
|
||||
score_buckets["0.6-0.8"] += 1
|
||||
elif cs >= 0.4:
|
||||
score_buckets["0.4-0.6"] += 1
|
||||
elif cs >= 0.2:
|
||||
score_buckets["0.2-0.4"] += 1
|
||||
else:
|
||||
score_buckets["0.0-0.2"] += 1
|
||||
|
||||
# -- ML frameworks --
|
||||
ml_keywords = {
|
||||
"torch": "PyTorch", "pytorch": "PyTorch",
|
||||
"tensorflow": "TensorFlow", "tf-": "TensorFlow",
|
||||
"jax": "JAX", "flax": "JAX",
|
||||
"keras": "Keras", "sklearn": "scikit-learn",
|
||||
"transformers": "HuggingFace Transformers",
|
||||
"lightning": "PyTorch Lightning", "paddle": "PaddlePaddle",
|
||||
}
|
||||
ml_frameworks: dict[str, int] = {}
|
||||
for rec in repos:
|
||||
topics = rec.get("topics", []) or []
|
||||
desc = (rec.get("description") or "").lower()
|
||||
readme = (rec.get("readme_excerpt") or "").lower()
|
||||
combined = desc + " " + readme + " " + " ".join(str(t) for t in topics)
|
||||
found_for_repo: set[str] = set()
|
||||
for kw, framework in ml_keywords.items():
|
||||
if kw in combined:
|
||||
found_for_repo.add(framework)
|
||||
for fw in found_for_repo:
|
||||
ml_frameworks[fw] = ml_frameworks.get(fw, 0) + 1
|
||||
ml_frameworks = dict(sorted(ml_frameworks.items(), key=lambda x: -x[1]))
|
||||
|
||||
# -- Licenses --
|
||||
licenses: dict[str, int] = {}
|
||||
for rec in repos:
|
||||
lic = rec.get("license") or "None/Unknown"
|
||||
if isinstance(lic, dict):
|
||||
lic = lic.get("spdx_id") or lic.get("name") or "None/Unknown"
|
||||
licenses[lic] = licenses.get(lic, 0) + 1
|
||||
licenses = dict(sorted(licenses.items(), key=lambda x: -x[1]))
|
||||
|
||||
# -- Counts for filtered / analyzed --
|
||||
scored_repos = [r for r in repos if r.get("composite_score")]
|
||||
analyzed_repos = [r for r in repos if r.get("tags") and "deep-dived" in r.get("tags", [])]
|
||||
# Fallback: count repos with rank field as proxy for filtered
|
||||
ranked_repos = [r for r in repos if r.get("rank")]
|
||||
|
||||
return {
|
||||
"total_discovered": len(repos),
|
||||
"total_filtered": len(ranked_repos) if ranked_repos else len(scored_repos),
|
||||
"total_analyzed": len(analyzed_repos),
|
||||
"num_sources": len(sources),
|
||||
"sources": sources,
|
||||
"languages": languages,
|
||||
"stars": stars_info,
|
||||
"activity": activity,
|
||||
"score_buckets": score_buckets,
|
||||
"ml_frameworks": ml_frameworks,
|
||||
"licenses": licenses,
|
||||
}
|
||||
|
||||
|
||||
# -- Report sections ----------------------------------------------------------
|
||||
|
||||
def build_header(topic_dir: str) -> str:
|
||||
"""Build the report header with topic name and date."""
|
||||
slug = os.path.basename(topic_dir.rstrip("/"))
|
||||
topic = slug.replace("-", " ").title()
|
||||
date = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
return f"# GitHub Research Report: {topic}\n\nGenerated: {date}\n"
|
||||
|
||||
|
||||
def build_executive_summary(stats: dict, blueprint_summary: str) -> str:
|
||||
"""Build the executive summary section."""
|
||||
lines = ["## Executive Summary\n"]
|
||||
if blueprint_summary:
|
||||
lines.append(blueprint_summary.strip())
|
||||
else:
|
||||
total = stats["total_discovered"]
|
||||
ns = stats["num_sources"]
|
||||
filtered = stats["total_filtered"]
|
||||
analyzed = stats["total_analyzed"]
|
||||
top_lang = next(iter(stats["languages"]), "N/A")
|
||||
active = stats["activity"].get("last_90d", 0)
|
||||
lines.append(
|
||||
f"This report covers a systematic GitHub research effort that discovered "
|
||||
f"**{total} repositories** from **{ns} source(s)**. "
|
||||
f"After scoring and filtering, **{filtered}** repos were shortlisted "
|
||||
f"and **{analyzed or 'several'}** received deep code analysis. "
|
||||
f"The dominant language is **{top_lang}** and "
|
||||
f"**{active}** repos have been active in the last 90 days."
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_discovery_stats(stats: dict) -> str:
|
||||
"""Build the discovery statistics section."""
|
||||
lines = ["## 1. Discovery Statistics\n"]
|
||||
lines.append(
|
||||
f"- Repos discovered: **{stats['total_discovered']}** "
|
||||
f"from **{stats['num_sources']}** sources"
|
||||
)
|
||||
lines.append(
|
||||
f"- After filtering: **{stats['total_filtered']}** repos "
|
||||
f"(top by composite score)"
|
||||
)
|
||||
lines.append(f"- Deep-dived: **{stats['total_analyzed']}** repos\n")
|
||||
|
||||
# Source table
|
||||
if stats["sources"]:
|
||||
lines.append("### Repos by Source\n")
|
||||
rows = [[src, str(cnt)] for src, cnt in
|
||||
sorted(stats["sources"].items(), key=lambda x: -x[1])]
|
||||
lines.append(format_table(["Source", "Count"], rows))
|
||||
lines.append("")
|
||||
|
||||
# Language table
|
||||
if stats["languages"]:
|
||||
lines.append("### Repos by Language\n")
|
||||
rows = [[lang, str(cnt)] for lang, cnt in list(stats["languages"].items())[:15]]
|
||||
lines.append(format_table(["Language", "Count"], rows))
|
||||
lines.append("")
|
||||
|
||||
# Stars distribution
|
||||
stars = stats.get("stars", {})
|
||||
if stars:
|
||||
lines.append("### Stars Distribution\n")
|
||||
lines.append(f"- Min: **{stars.get('min', 0)}** | "
|
||||
f"Max: **{stars.get('max', 0)}** | "
|
||||
f"Median: **{stars.get('median', 0)}** | "
|
||||
f"Mean: **{stars.get('mean', 0)}**")
|
||||
lines.append(f"- P25: **{stars.get('p25', 0)}** | P75: **{stars.get('p75', 0)}**\n")
|
||||
|
||||
# Score buckets
|
||||
if stats["score_buckets"]:
|
||||
lines.append("### Composite Score Distribution\n")
|
||||
rows = [[bucket, str(cnt)] for bucket, cnt in stats["score_buckets"].items()]
|
||||
lines.append(format_table(["Score Range", "Count"], rows))
|
||||
lines.append("")
|
||||
|
||||
# Activity
|
||||
if stats["activity"]:
|
||||
lines.append("### Activity\n")
|
||||
act = stats["activity"]
|
||||
lines.append(f"- Pushed in last 90 days: **{act.get('last_90d', 0)}**")
|
||||
lines.append(f"- Pushed in last year: **{act.get('last_year', 0)}**")
|
||||
lines.append(f"- Older / unknown: **{act.get('older', 0)}**\n")
|
||||
|
||||
# ML frameworks
|
||||
if stats["ml_frameworks"]:
|
||||
lines.append("### ML Frameworks\n")
|
||||
rows = [[fw, str(cnt)] for fw, cnt in stats["ml_frameworks"].items()]
|
||||
lines.append(format_table(["Framework", "Repos"], rows))
|
||||
lines.append("")
|
||||
|
||||
# Licenses
|
||||
if stats["licenses"]:
|
||||
lines.append("### License Distribution\n")
|
||||
rows = [[lic, str(cnt)] for lic, cnt in list(stats["licenses"].items())[:10]]
|
||||
lines.append(format_table(["License", "Count"], rows))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_top_repos(ranked_repos: list[dict]) -> str:
|
||||
"""Build the top repositories table from ranked_repos.jsonl."""
|
||||
lines = ["## 2. Top Repositories\n"]
|
||||
if not ranked_repos:
|
||||
lines.append("_No ranked repos available._\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
headers = ["Rank", "Repo", "Stars", "Language", "Composite", "Description"]
|
||||
rows: list[list[str]] = []
|
||||
for i, rec in enumerate(ranked_repos[:30], 1):
|
||||
rid = rec.get("repo_id", "")
|
||||
url = rec.get("url", f"https://github.com/{rid}")
|
||||
stars = str(rec.get("stars", 0))
|
||||
lang = rec.get("language", "")
|
||||
score = f"{(rec.get('composite_score', 0.0) or 0.0):.3f}"
|
||||
desc = truncate((rec.get("description") or "").replace("|", "/"), 60)
|
||||
rows.append([str(i), f"[{rid}]({url})", stars, lang, score, desc])
|
||||
|
||||
lines.append(format_table(headers, rows))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_deep_analysis_summaries(analyses_dir: str) -> str:
|
||||
"""Build condensed summaries from per-repo analyses/*.md files."""
|
||||
lines = ["## 3. Deep Analysis Summaries\n"]
|
||||
if not os.path.isdir(analyses_dir):
|
||||
lines.append("_No deep analysis files found._\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
analysis_files = sorted(
|
||||
f for f in os.listdir(analyses_dir) if f.endswith("_analysis.md")
|
||||
)
|
||||
if not analysis_files:
|
||||
lines.append("_No per-repo analysis files found._\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
for fname in analysis_files:
|
||||
content = load_text(os.path.join(analyses_dir, fname))
|
||||
if not content:
|
||||
continue
|
||||
# Extract repo name from filename
|
||||
repo_name = fname.removesuffix("_analysis.md")
|
||||
lines.append(f"### {repo_name}\n")
|
||||
# Include the first ~600 chars as a condensed summary
|
||||
lines.append(truncate(content.strip(), 600))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_section(title: str, content: str) -> str:
|
||||
"""Wrap phase content in a section. Return empty section note if no content."""
|
||||
lines = [f"{title}\n"]
|
||||
if content.strip():
|
||||
lines.append(content.strip())
|
||||
else:
|
||||
heading_text = title.lstrip("#").strip()
|
||||
lines.append(f"_{heading_text} not available._")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_appendix(repos: list[dict]) -> str:
|
||||
"""Build the full repo database appendix table."""
|
||||
lines = ["## Appendix A: Full Repo Database\n"]
|
||||
if not repos:
|
||||
lines.append("_No repos in database._\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
sorted_repos = sorted(repos, key=lambda r: -(r.get("composite_score", 0) or 0))
|
||||
headers = ["Repo", "Stars", "Language", "Score", "Source", "Description"]
|
||||
rows: list[list[str]] = []
|
||||
for rec in sorted_repos:
|
||||
rid = rec.get("repo_id", "")
|
||||
url = rec.get("url", f"https://github.com/{rid}")
|
||||
stars = str(rec.get("stars", 0))
|
||||
lang = rec.get("language", "")
|
||||
score = f"{(rec.get('composite_score', 0.0) or 0.0):.3f}"
|
||||
src = rec.get("source", "")
|
||||
desc = truncate((rec.get("description") or "").replace("|", "/"), 60)
|
||||
rows.append([f"[{rid}]({url})", stars, lang, score, src, desc])
|
||||
|
||||
lines.append(format_table(headers, rows))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_methodology() -> str:
|
||||
"""Build the methodology appendix."""
|
||||
return """## Appendix B: Methodology
|
||||
|
||||
- **Phase 1: Intake** -- Parse deep-research output for GitHub URLs, paper references, and search keywords
|
||||
- **Phase 2: Discovery** -- Multi-source search (GitHub repo search, Papers With Code, GitHub code search, direct URLs)
|
||||
- **Phase 3: Scoring & Filtering** -- Composite score = relevance x 0.4 + quality x 0.35 + activity x 0.25; select top repos
|
||||
- **Phase 4: Deep Dive** -- Shallow clone + deep code reading of top repos (architecture, algorithms, quality, reusability)
|
||||
- **Phase 5: Cross-Repo Analysis** -- Comparison matrix, technique-to-code mapping, gap analysis
|
||||
- **Phase 6: Integration Blueprint** -- Recommended architecture, reuse catalog, license compatibility, effort estimates
|
||||
"""
|
||||
|
||||
|
||||
# -- Main ---------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Assemble final GitHub research report from all phase outputs."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--topic-dir", required=True,
|
||||
help="Path to the github-research-output/{slug}/ directory",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
topic_dir = args.topic_dir.rstrip("/")
|
||||
if not os.path.isdir(topic_dir):
|
||||
print(f"[error] topic directory not found: {topic_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[compile] loading data from {topic_dir}", file=sys.stderr)
|
||||
|
||||
# -- Load repo database --
|
||||
repo_db_path = os.path.join(topic_dir, "repo_db.jsonl")
|
||||
all_repos = load_jsonl(repo_db_path)
|
||||
print(f" repo_db.jsonl: {len(all_repos)} repos", file=sys.stderr)
|
||||
|
||||
# -- Load ranked repos --
|
||||
ranked_path = os.path.join(topic_dir, "phase3_filtering", "ranked_repos.jsonl")
|
||||
ranked_repos = load_jsonl(ranked_path)
|
||||
print(f" ranked_repos.jsonl: {len(ranked_repos)} repos", file=sys.stderr)
|
||||
|
||||
# -- Load phase text outputs --
|
||||
phase_paths = {
|
||||
"intake_summary": os.path.join(topic_dir, "phase1_intake", "intake_summary.md"),
|
||||
"discovery_log": os.path.join(topic_dir, "phase2_discovery", "discovery_log.md"),
|
||||
"filtering_report": os.path.join(topic_dir, "phase3_filtering", "filtering_report.md"),
|
||||
"deep_dive_summary": os.path.join(topic_dir, "phase4_deep_dive", "deep_dive_summary.md"),
|
||||
"comparison_matrix": os.path.join(topic_dir, "phase5_analysis", "comparison_matrix.md"),
|
||||
"technique_map": os.path.join(topic_dir, "phase5_analysis", "technique_map.md"),
|
||||
"analysis_report": os.path.join(topic_dir, "phase5_analysis", "analysis_report.md"),
|
||||
"integration_plan": os.path.join(topic_dir, "phase6_blueprint", "integration_plan.md"),
|
||||
"reuse_catalog": os.path.join(topic_dir, "phase6_blueprint", "reuse_catalog.md"),
|
||||
"blueprint_summary": os.path.join(topic_dir, "phase6_blueprint", "blueprint_summary.md"),
|
||||
}
|
||||
|
||||
phases: dict[str, str] = {}
|
||||
for key, path in phase_paths.items():
|
||||
phases[key] = load_text(path)
|
||||
status = "loaded" if phases[key] else "not found"
|
||||
print(f" {key}: {status}", file=sys.stderr)
|
||||
|
||||
analyses_dir = os.path.join(topic_dir, "phase4_deep_dive", "analyses")
|
||||
|
||||
# -- Compute statistics --
|
||||
stats = compute_stats(all_repos)
|
||||
# Override total_filtered with actual ranked count if available
|
||||
if ranked_repos:
|
||||
stats["total_filtered"] = len(ranked_repos)
|
||||
# Count analysis files for total_analyzed
|
||||
if os.path.isdir(analyses_dir):
|
||||
analysis_count = sum(
|
||||
1 for f in os.listdir(analyses_dir) if f.endswith("_analysis.md")
|
||||
)
|
||||
if analysis_count > 0:
|
||||
stats["total_analyzed"] = analysis_count
|
||||
|
||||
print(f"[compile] assembling report...", file=sys.stderr)
|
||||
|
||||
# -- Assemble report --
|
||||
sections: list[str] = [
|
||||
build_header(topic_dir),
|
||||
"---\n",
|
||||
build_executive_summary(stats, phases["blueprint_summary"]),
|
||||
build_discovery_stats(stats),
|
||||
build_top_repos(ranked_repos if ranked_repos else all_repos),
|
||||
build_deep_analysis_summaries(analyses_dir),
|
||||
build_section("## 4. Cross-Repository Comparison", phases["comparison_matrix"]),
|
||||
build_section("## 5. Technique-to-Code Mapping", phases["technique_map"]),
|
||||
build_section("## 6. Integration Blueprint", phases["integration_plan"]),
|
||||
build_section("## 7. Reusable Components", phases["reuse_catalog"]),
|
||||
build_appendix(all_repos),
|
||||
build_methodology(),
|
||||
]
|
||||
|
||||
report = "\n".join(sections)
|
||||
|
||||
# -- Write outputs --
|
||||
output_dir = os.path.join(topic_dir, "phase6_blueprint")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
report_path = os.path.join(output_dir, "final_report.md")
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
|
||||
stats_path = os.path.join(output_dir, "stats.json")
|
||||
with open(stats_path, "w", encoding="utf-8") as f:
|
||||
json.dump(stats, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# -- Summary --
|
||||
section_count = sum(1 for s in sections if s.strip().startswith("##"))
|
||||
print(
|
||||
f"\nReport compiled: {section_count} sections, "
|
||||
f"{stats['total_discovered']} repos, "
|
||||
f"{stats['total_analyzed']} analyzed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f" -> {report_path}", file=sys.stderr)
|
||||
print(f" -> {stats_path}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+543
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract and parse dependency files from a cloned repository.
|
||||
|
||||
Identifies Python, Node, Rust, Go, and system dependencies.
|
||||
Self-contained: stdlib only (regex-based parsing, no toml/yaml libraries).
|
||||
|
||||
Usage:
|
||||
python extract_dependencies.py --repo-dir ./repos/owner_name --output deps.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ML framework detection mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ML_FRAMEWORK_MAP: dict[str, str] = {
|
||||
# PyTorch ecosystem
|
||||
"torch": "pytorch", "torchvision": "pytorch", "torchaudio": "pytorch",
|
||||
"pytorch-lightning": "pytorch", "lightning": "pytorch",
|
||||
# TensorFlow ecosystem
|
||||
"tensorflow": "tensorflow", "tensorflow-gpu": "tensorflow",
|
||||
"tf-nightly": "tensorflow", "tf-estimator-nightly": "tensorflow",
|
||||
"keras": "tensorflow",
|
||||
# JAX ecosystem
|
||||
"jax": "jax", "jaxlib": "jax", "flax": "jax", "optax": "jax",
|
||||
# HuggingFace ecosystem
|
||||
"transformers": "huggingface", "datasets": "huggingface",
|
||||
"tokenizers": "huggingface", "accelerate": "huggingface",
|
||||
"diffusers": "huggingface", "peft": "huggingface",
|
||||
# scikit-learn
|
||||
"scikit-learn": "scikit-learn", "sklearn": "scikit-learn",
|
||||
# Other ML tools
|
||||
"onnx": "onnx", "onnxruntime": "onnx",
|
||||
"tensorrt": "tensorrt", "triton": "triton",
|
||||
"deepspeed": "deepspeed", "fairscale": "fairscale",
|
||||
}
|
||||
|
||||
SYSTEM_KEYWORDS: list[str] = [
|
||||
"cuda", "cudnn", "nvidia", "ffmpeg", "libsndfile", "sox",
|
||||
"opencv", "libgl", "libglib", "cmake", "gcc", "g++",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
"""Read a file as UTF-8, returning None on failure."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_version(spec: str) -> str:
|
||||
"""Extract bare package name from a version specifier string."""
|
||||
return re.split(r"[><=!~;\[\s@]", spec.strip())[0].strip().lower()
|
||||
|
||||
|
||||
def _extract_quoted_strings(text: str) -> list[str]:
|
||||
"""Extract all single- or double-quoted strings from text."""
|
||||
return re.findall(r"""['"]([^'"]+)['"]""", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python: requirements.txt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_requirements_txt(repo_dir: Path) -> dict | None:
|
||||
"""Parse requirements*.txt files."""
|
||||
candidates = [
|
||||
"requirements.txt", "requirements-dev.txt", "requirements_dev.txt",
|
||||
"requirements-test.txt", "requirements_test.txt",
|
||||
"requirements/base.txt", "requirements/main.txt",
|
||||
]
|
||||
all_specs: list[str] = []
|
||||
source_files: list[str] = []
|
||||
|
||||
for name in candidates:
|
||||
path = repo_dir / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
source_files.append(name)
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("-"):
|
||||
continue
|
||||
all_specs.append(line)
|
||||
|
||||
if not all_specs:
|
||||
return None
|
||||
# Deduplicate while preserving order
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for spec in all_specs:
|
||||
if spec not in seen:
|
||||
seen.add(spec)
|
||||
deduped.append(spec)
|
||||
return {"requirements": deduped, "source_file": ", ".join(source_files)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python: setup.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_setup_py(repo_dir: Path) -> dict | None:
|
||||
"""Parse setup.py for install_requires and extras_require."""
|
||||
path = repo_dir / "setup.py"
|
||||
if not path.is_file():
|
||||
return None
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
specs: list[str] = []
|
||||
|
||||
# install_requires = [...]
|
||||
match = re.search(r"install_requires\s*=\s*\[([^\]]+)\]", text, re.DOTALL)
|
||||
if match:
|
||||
specs.extend(_extract_quoted_strings(match.group(1)))
|
||||
|
||||
# extras_require = { ... }
|
||||
match = re.search(r"extras_require\s*=\s*\{([^}]+)\}", text, re.DOTALL)
|
||||
if match:
|
||||
specs.extend(_extract_quoted_strings(match.group(1)))
|
||||
|
||||
if not specs:
|
||||
return None
|
||||
return {"requirements": specs, "source_file": "setup.py"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python: pyproject.toml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_pyproject_toml(repo_dir: Path) -> dict | None:
|
||||
"""Parse pyproject.toml for dependencies (regex-based, no toml lib)."""
|
||||
path = repo_dir / "pyproject.toml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
specs: list[str] = []
|
||||
|
||||
# [project] dependencies = [...]
|
||||
match = re.search(r"dependencies\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
|
||||
if match:
|
||||
specs.extend(_extract_quoted_strings(match.group(1)))
|
||||
|
||||
# [project.optional-dependencies] section -- grab all arrays
|
||||
section_match = re.search(
|
||||
r"\[project\.optional-dependencies\](.*?)(?=\n\[|\Z)", text, re.DOTALL,
|
||||
)
|
||||
if section_match:
|
||||
for array_match in re.finditer(r"=\s*\[([^\]]*)\]", section_match.group(1)):
|
||||
specs.extend(_extract_quoted_strings(array_match.group(1)))
|
||||
|
||||
if not specs:
|
||||
return None
|
||||
return {"requirements": specs, "source_file": "pyproject.toml"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python: environment.yml / environment.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_environment_yml(repo_dir: Path) -> dict | None:
|
||||
"""Parse environment.yml / environment.yaml for conda dependencies."""
|
||||
source_name: str | None = None
|
||||
for name in ("environment.yml", "environment.yaml"):
|
||||
path = repo_dir / name
|
||||
if path.is_file():
|
||||
source_name = name
|
||||
break
|
||||
if source_name is None:
|
||||
return None
|
||||
|
||||
text = _read_text(repo_dir / source_name)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
specs: list[str] = []
|
||||
in_deps = False
|
||||
in_pip = False
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Detect dependencies: section
|
||||
if re.match(r"^dependencies\s*:", stripped):
|
||||
in_deps = True
|
||||
continue
|
||||
|
||||
if not in_deps:
|
||||
continue
|
||||
|
||||
# End of section on non-indented, non-list line
|
||||
if stripped and not stripped.startswith("-") and not stripped.startswith("#"):
|
||||
if not in_pip:
|
||||
in_deps = False
|
||||
continue
|
||||
|
||||
# pip sub-section
|
||||
if stripped == "- pip:" or stripped == "- pip":
|
||||
in_pip = True
|
||||
continue
|
||||
if in_pip:
|
||||
if stripped.startswith("- "):
|
||||
pkg = stripped[2:].strip()
|
||||
if pkg:
|
||||
specs.append(pkg)
|
||||
continue
|
||||
if not stripped.startswith("-") and stripped:
|
||||
in_pip = False
|
||||
|
||||
if stripped.startswith("- "):
|
||||
pkg = stripped[2:].strip()
|
||||
if pkg and pkg not in ("pip", "pip:"):
|
||||
specs.append(pkg)
|
||||
|
||||
if not specs:
|
||||
return None
|
||||
return {"requirements": specs, "source_file": source_name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node: package.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_package_json(repo_dir: Path) -> dict | None:
|
||||
"""Parse package.json for dependencies and devDependencies."""
|
||||
path = repo_dir / "package.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
deps = data.get("dependencies") or {}
|
||||
dev_deps = data.get("devDependencies") or {}
|
||||
if not deps and not dev_deps:
|
||||
return None
|
||||
|
||||
result: dict = {"source_file": "package.json"}
|
||||
if deps:
|
||||
result["dependencies"] = deps
|
||||
if dev_deps:
|
||||
result["devDependencies"] = dev_deps
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rust: Cargo.toml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_cargo_toml(repo_dir: Path) -> dict | None:
|
||||
"""Parse Cargo.toml for [dependencies] section."""
|
||||
path = repo_dir / "Cargo.toml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
deps: dict[str, str] = {}
|
||||
match = re.search(
|
||||
r"\[dependencies\](.*?)(?=\n\[|\Z)", text, re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
for line in match.group(1).splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# name = "version"
|
||||
m = re.match(r'^(\S+)\s*=\s*"([^"]*)"', line)
|
||||
if m:
|
||||
deps[m.group(1)] = m.group(2)
|
||||
else:
|
||||
# name = { version = "...", ... }
|
||||
m = re.match(r"^(\S+)\s*=\s*\{", line)
|
||||
if m:
|
||||
ver = re.search(r'version\s*=\s*"([^"]*)"', line)
|
||||
deps[m.group(1)] = ver.group(1) if ver else "*"
|
||||
|
||||
if not deps:
|
||||
return None
|
||||
return {"dependencies": deps, "source_file": "Cargo.toml"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Go: go.mod
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_go_mod(repo_dir: Path) -> dict | None:
|
||||
"""Parse go.mod for require block."""
|
||||
path = repo_dir / "go.mod"
|
||||
if not path.is_file():
|
||||
return None
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
modules: list[str] = []
|
||||
in_require = False
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("require ("):
|
||||
in_require = True
|
||||
continue
|
||||
if in_require:
|
||||
if stripped == ")":
|
||||
in_require = False
|
||||
continue
|
||||
parts = stripped.split()
|
||||
if parts and not parts[0].startswith("//"):
|
||||
modules.append(parts[0])
|
||||
elif stripped.startswith("require "):
|
||||
parts = stripped.split()
|
||||
if len(parts) >= 2:
|
||||
modules.append(parts[1])
|
||||
|
||||
if not modules:
|
||||
return None
|
||||
return {"modules": modules, "source_file": "go.mod"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System dependency detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_system_deps(repo_dir: Path) -> list[str]:
|
||||
"""Detect system-level dependencies from Dockerfile, README, setup.py."""
|
||||
found: set[str] = set()
|
||||
scan_files = [
|
||||
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
|
||||
"README.md", "README.rst", "readme.md",
|
||||
"setup.py", "Makefile", "CMakeLists.txt",
|
||||
]
|
||||
|
||||
for name in scan_files:
|
||||
path = repo_dir / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
text_lower = text.lower()
|
||||
|
||||
# Check for known system keywords
|
||||
for kw in SYSTEM_KEYWORDS:
|
||||
if kw in text_lower:
|
||||
found.add(kw)
|
||||
|
||||
# Extract apt-get install packages from Dockerfiles
|
||||
if name.lower().startswith("dockerfile") or name.lower() == "dockerfile":
|
||||
for m in re.finditer(r"apt-get\s+install[^&\n]*", text):
|
||||
tokens = m.group(0).split()
|
||||
for tok in tokens:
|
||||
tok = tok.strip().rstrip("\\")
|
||||
if tok and not tok.startswith("-") and tok not in (
|
||||
"apt-get", "install", "&&", "||", "RUN",
|
||||
):
|
||||
found.add(tok)
|
||||
|
||||
return sorted(found)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ML framework detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_ml_frameworks(all_packages: list[str]) -> list[str]:
|
||||
"""Identify ML frameworks from the combined package list."""
|
||||
frameworks: set[str] = set()
|
||||
for pkg in all_packages:
|
||||
pkg_lower = pkg.lower()
|
||||
if pkg_lower in ML_FRAMEWORK_MAP:
|
||||
frameworks.add(ML_FRAMEWORK_MAP[pkg_lower])
|
||||
# Handle tf-* prefix patterns
|
||||
elif pkg_lower.startswith("tf-") or pkg_lower.startswith("tensorflow-"):
|
||||
frameworks.add("tensorflow")
|
||||
return sorted(frameworks)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract and parse dependency files from a cloned repository",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-dir", required=True,
|
||||
help="Path to cloned repository directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", required=True,
|
||||
help="Output JSON file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-id", default=None,
|
||||
help="Repository identifier (owner/name); inferred from dir name if omitted",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_dir = Path(args.repo_dir).resolve()
|
||||
if not repo_dir.is_dir():
|
||||
print(f"Error: repo directory not found: {repo_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Infer repo_id from directory name if not provided
|
||||
repo_id: str = args.repo_id or ""
|
||||
if not repo_id:
|
||||
dir_name = repo_dir.name
|
||||
repo_id = dir_name.replace("_", "/", 1) if "_" in dir_name else dir_name
|
||||
|
||||
print(f"Extracting dependencies from {repo_dir} ...", file=sys.stderr)
|
||||
|
||||
# --- Python: merge results from all parsers ---
|
||||
python_info: dict | None = None
|
||||
for parser_fn in (parse_requirements_txt, parse_setup_py,
|
||||
parse_pyproject_toml, parse_environment_yml):
|
||||
result = parser_fn(repo_dir)
|
||||
if result is not None:
|
||||
if python_info is None:
|
||||
python_info = result
|
||||
else:
|
||||
# Merge: append specs, combine source_file
|
||||
existing = set(python_info["requirements"])
|
||||
for spec in result["requirements"]:
|
||||
if spec not in existing:
|
||||
python_info["requirements"].append(spec)
|
||||
existing.add(spec)
|
||||
python_info["source_file"] += f", {result['source_file']}"
|
||||
if python_info:
|
||||
print(f" Python: {len(python_info['requirements'])} packages "
|
||||
f"from {python_info['source_file']}", file=sys.stderr)
|
||||
|
||||
# --- Node ---
|
||||
node_info = parse_package_json(repo_dir)
|
||||
if node_info:
|
||||
n = len(node_info.get("dependencies", {})) + len(node_info.get("devDependencies", {}))
|
||||
print(f" Node: {n} packages from {node_info['source_file']}", file=sys.stderr)
|
||||
|
||||
# --- Rust ---
|
||||
rust_info = parse_cargo_toml(repo_dir)
|
||||
if rust_info:
|
||||
print(f" Rust: {len(rust_info['dependencies'])} crates "
|
||||
f"from {rust_info['source_file']}", file=sys.stderr)
|
||||
|
||||
# --- Go ---
|
||||
go_info = parse_go_mod(repo_dir)
|
||||
if go_info:
|
||||
print(f" Go: {len(go_info['modules'])} modules "
|
||||
f"from {go_info['source_file']}", file=sys.stderr)
|
||||
|
||||
# --- Collect all package names (flat, normalized) ---
|
||||
all_packages: set[str] = set()
|
||||
|
||||
if python_info:
|
||||
for spec in python_info["requirements"]:
|
||||
name = _strip_version(spec)
|
||||
if name:
|
||||
all_packages.add(name)
|
||||
|
||||
if node_info:
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
for pkg_name in node_info.get(key, {}):
|
||||
all_packages.add(pkg_name.lower())
|
||||
|
||||
if rust_info:
|
||||
for crate_name in rust_info["dependencies"]:
|
||||
all_packages.add(crate_name.lower())
|
||||
|
||||
if go_info:
|
||||
for mod_path in go_info["modules"]:
|
||||
# Use last path segment as package name
|
||||
parts = mod_path.rstrip("/").split("/")
|
||||
all_packages.add(parts[-1].lower())
|
||||
|
||||
all_packages_sorted = sorted(all_packages)
|
||||
|
||||
# --- System dependencies ---
|
||||
system_deps = detect_system_deps(repo_dir)
|
||||
if system_deps:
|
||||
print(f" System: {len(system_deps)} system dependencies detected",
|
||||
file=sys.stderr)
|
||||
|
||||
# --- ML frameworks ---
|
||||
ml_frameworks = detect_ml_frameworks(all_packages_sorted)
|
||||
if ml_frameworks:
|
||||
print(f" ML frameworks: {', '.join(ml_frameworks)}", file=sys.stderr)
|
||||
|
||||
# --- Build output ---
|
||||
output: dict = {
|
||||
"repo_id": repo_id,
|
||||
"python": python_info,
|
||||
"node": node_info,
|
||||
"rust": rust_info,
|
||||
"go": go_info,
|
||||
"system": system_deps,
|
||||
"ml_frameworks": ml_frameworks,
|
||||
"all_packages": all_packages_sorted,
|
||||
"overlap_with": {},
|
||||
}
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
ecosystems = sum(1 for x in (python_info, node_info, rust_info, go_info)
|
||||
if x is not None)
|
||||
print(
|
||||
f"Extracted {len(all_packages_sorted)} packages across "
|
||||
f"{ecosystems} ecosystem(s) -> {args.output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract GitHub URLs, paper refs, and keywords from deep-research output.
|
||||
|
||||
Scans markdown files for GitHub URLs (cleaning sub-paths to extract owner/name),
|
||||
parses paper_db.jsonl for paper metadata, extracts keywords from titles and tags,
|
||||
and outputs deduplicated JSONL records.
|
||||
|
||||
Usage:
|
||||
python extract_research_refs.py --research-dir ./deep-research-output/topic/ --output refs.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
GITHUB_URL_RE = re.compile(r'https://github\.com/[^\s\)\]\>\"\']+')
|
||||
|
||||
# Sub-paths to strip to get base repo URL
|
||||
REPO_SUBPATH_RE = re.compile(
|
||||
r"/(?:tree|blob|issues|pull|releases|wiki|actions|commits|compare|archive|raw|discussions|pkgs|packages|security|stargazers|network)(?:/.*)?$"
|
||||
)
|
||||
|
||||
# Non-repo top-level GitHub pages
|
||||
NON_REPO_OWNERS = frozenset({
|
||||
"topics", "explore", "trending", "settings",
|
||||
"organizations", "sponsors", "features", "marketplace",
|
||||
"notifications", "new", "login", "signup", "pricing",
|
||||
})
|
||||
|
||||
# Common English stopwords for keyword filtering
|
||||
STOPWORDS = frozenset({
|
||||
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
||||
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
|
||||
"being", "have", "has", "had", "do", "does", "did", "will", "would",
|
||||
"could", "should", "may", "might", "can", "shall", "not", "no", "nor",
|
||||
"so", "if", "then", "than", "that", "this", "these", "those", "it",
|
||||
"its", "we", "our", "they", "their", "them", "you", "your", "he",
|
||||
"she", "his", "her", "as", "up", "out", "about", "into", "over",
|
||||
"after", "before", "between", "through", "during", "each", "every",
|
||||
"all", "both", "few", "more", "most", "other", "some", "such", "only",
|
||||
"same", "also", "how", "what", "which", "who", "when", "where", "why",
|
||||
"very", "just", "because", "while", "using", "via", "based", "new",
|
||||
"one", "two", "first", "use", "used", "paper", "method", "approach",
|
||||
"proposed", "show", "results", "model", "models",
|
||||
})
|
||||
|
||||
|
||||
def clean_github_url(raw_url: str) -> tuple[str, str]:
|
||||
"""Clean a raw GitHub URL and extract owner/name repo_id.
|
||||
|
||||
Returns (clean_url, repo_id) or ("", "") if invalid.
|
||||
"""
|
||||
url = raw_url.rstrip(".,;:!?)]\">'/")
|
||||
url = url.rstrip("/")
|
||||
|
||||
# Remove sub-paths (tree, blob, issues, etc.) to get base repo URL
|
||||
url = REPO_SUBPATH_RE.sub("", url)
|
||||
|
||||
# Extract owner/name
|
||||
path = url.replace("https://github.com/", "")
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 2 and parts[0] and parts[1]:
|
||||
owner, name = parts[0], parts[1]
|
||||
# Filter out non-repo pages
|
||||
if owner.lower() in NON_REPO_OWNERS:
|
||||
return "", ""
|
||||
# Strip .git suffix
|
||||
name = name.removesuffix(".git")
|
||||
repo_id = f"{owner}/{name}"
|
||||
clean_url = f"https://github.com/{repo_id}"
|
||||
return clean_url, repo_id
|
||||
return "", ""
|
||||
|
||||
|
||||
def scan_md_files_for_urls(research_dir: Path) -> list[dict]:
|
||||
"""Scan all .md files recursively for GitHub URLs."""
|
||||
results: list[dict] = []
|
||||
seen_repos: set[str] = set()
|
||||
|
||||
for md_file in sorted(research_dir.rglob("*.md")):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
print(f"[warn] cannot read {md_file}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
rel_path = str(md_file.relative_to(research_dir))
|
||||
|
||||
for match in GITHUB_URL_RE.finditer(text):
|
||||
raw_url = match.group(0)
|
||||
clean_url, repo_id = clean_github_url(raw_url)
|
||||
if not repo_id:
|
||||
continue
|
||||
|
||||
# Extract surrounding context (the line containing the URL)
|
||||
line_start = text.rfind("\n", 0, match.start()) + 1
|
||||
line_end = text.find("\n", match.end())
|
||||
if line_end == -1:
|
||||
line_end = len(text)
|
||||
context = text[line_start:line_end].strip()
|
||||
|
||||
# Deduplicate by repo_id (not raw URL) to merge /tree/... variants
|
||||
if repo_id in seen_repos:
|
||||
continue
|
||||
seen_repos.add(repo_id)
|
||||
|
||||
results.append({
|
||||
"type": "github_url",
|
||||
"url": clean_url,
|
||||
"repo_id": repo_id,
|
||||
"source_file": rel_path,
|
||||
"context": context[:200],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def parse_paper_db(research_dir: Path) -> list[dict]:
|
||||
"""Parse paper_db.jsonl for paper titles, arxiv IDs, tags."""
|
||||
results: list[dict] = []
|
||||
paper_db = research_dir / "paper_db.jsonl"
|
||||
if not paper_db.exists():
|
||||
print("[info] paper_db.jsonl not found, skipping paper extraction", file=sys.stderr)
|
||||
return results
|
||||
|
||||
try:
|
||||
for line_num, line in enumerate(paper_db.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
paper = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[warn] paper_db.jsonl line {line_num}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
title = paper.get("title", "")
|
||||
arxiv_id = paper.get("arxiv_id", paper.get("id", ""))
|
||||
tags = paper.get("tags", paper.get("keywords", paper.get("categories", [])))
|
||||
paper_id = paper.get("paperId", arxiv_id)
|
||||
|
||||
results.append({
|
||||
"type": "paper",
|
||||
"title": title,
|
||||
"arxiv_id": arxiv_id,
|
||||
"tags": tags if isinstance(tags, list) else [],
|
||||
"paper_id": paper_id,
|
||||
})
|
||||
except OSError as e:
|
||||
print(f"[warn] cannot read paper_db.jsonl: {e}", file=sys.stderr)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def extract_keywords(papers: list[dict]) -> list[dict]:
|
||||
"""Extract search keywords from paper titles and tags."""
|
||||
tag_counter: Counter[str] = Counter()
|
||||
title_term_counter: Counter[str] = Counter()
|
||||
|
||||
for paper in papers:
|
||||
# Count tags
|
||||
for tag in paper.get("tags", []):
|
||||
if isinstance(tag, str):
|
||||
tag_lower = tag.strip().lower()
|
||||
if tag_lower and len(tag_lower) > 2:
|
||||
tag_counter[tag_lower] += 1
|
||||
|
||||
# Extract terms from titles
|
||||
title = paper.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
words = re.findall(r"[A-Za-z][-A-Za-z]+", title)
|
||||
filtered = [w.lower() for w in words
|
||||
if w.lower() not in STOPWORDS and len(w) > 2]
|
||||
|
||||
# Unigrams
|
||||
for w in filtered:
|
||||
title_term_counter[w] += 1
|
||||
|
||||
# Bigrams (multi-word technical terms)
|
||||
for i in range(len(filtered) - 1):
|
||||
bigram = f"{filtered[i]} {filtered[i + 1]}"
|
||||
title_term_counter[bigram] += 1
|
||||
|
||||
keywords: list[dict] = []
|
||||
|
||||
# Tags with frequency
|
||||
for tag, freq in tag_counter.most_common():
|
||||
keywords.append({
|
||||
"type": "keyword",
|
||||
"value": tag,
|
||||
"frequency": freq,
|
||||
"source": "paper_tags",
|
||||
})
|
||||
|
||||
# Title terms (2+ occurrences, or bigrams with 1+ occurrence)
|
||||
for term, freq in title_term_counter.most_common():
|
||||
if freq >= 2 or (freq >= 1 and " " in term):
|
||||
# Skip if already covered by tags
|
||||
if term not in tag_counter:
|
||||
keywords.append({
|
||||
"type": "keyword",
|
||||
"value": term,
|
||||
"frequency": freq,
|
||||
"source": "paper_titles",
|
||||
})
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
def extract_synthesis_themes(research_dir: Path) -> list[dict]:
|
||||
"""Extract research themes from synthesis/report markdown headings."""
|
||||
results: list[dict] = []
|
||||
|
||||
# Check several possible locations for synthesis/report files
|
||||
candidates = [
|
||||
research_dir / "phase5_synthesis" / "synthesis.md",
|
||||
research_dir / "phase6_report" / "report.md",
|
||||
research_dir / "phase3_deep_dive" / "deep_dive.md",
|
||||
]
|
||||
|
||||
for filepath in candidates:
|
||||
if not filepath.exists():
|
||||
continue
|
||||
try:
|
||||
text = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for match in re.finditer(r'^#{1,3}\s+(.+)$', text, re.MULTILINE):
|
||||
heading = match.group(1).strip()
|
||||
if len(heading) > 3:
|
||||
results.append({
|
||||
"type": "keyword",
|
||||
"value": heading.lower(),
|
||||
"frequency": 1,
|
||||
"source": "paper_titles",
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract GitHub URLs, paper refs, and keywords from deep-research output."
|
||||
)
|
||||
parser.add_argument("--research-dir", required=True,
|
||||
help="Path to deep-research output directory")
|
||||
parser.add_argument("--output", required=True,
|
||||
help="Output JSONL file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
research_dir = Path(args.research_dir).resolve()
|
||||
if not research_dir.is_dir():
|
||||
print(f"[error] research dir not found: {research_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[info] scanning {research_dir} ...", file=sys.stderr)
|
||||
|
||||
# 1. Extract GitHub URLs from all markdown files
|
||||
github_refs = scan_md_files_for_urls(research_dir)
|
||||
print(f" GitHub URLs: {len(github_refs)}", file=sys.stderr)
|
||||
|
||||
# 2. Parse paper_db.jsonl
|
||||
papers = parse_paper_db(research_dir)
|
||||
print(f" Papers: {len(papers)}", file=sys.stderr)
|
||||
|
||||
# 3. Extract keywords from papers
|
||||
keywords = extract_keywords(papers)
|
||||
|
||||
# 4. Extract themes from synthesis/report headings
|
||||
themes = extract_synthesis_themes(research_dir)
|
||||
keywords.extend(themes)
|
||||
|
||||
# Deduplicate keywords by value
|
||||
seen_kw: set[str] = set()
|
||||
deduped_kw: list[dict] = []
|
||||
for kw in keywords:
|
||||
if kw["value"] not in seen_kw:
|
||||
seen_kw.add(kw["value"])
|
||||
deduped_kw.append(kw)
|
||||
keywords = deduped_kw
|
||||
|
||||
print(f" Keywords: {len(keywords)}", file=sys.stderr)
|
||||
|
||||
# Write output
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for record in github_refs:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
for record in papers:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
for record in keywords:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# Summary stats
|
||||
unique_repos = len({r["repo_id"] for r in github_refs})
|
||||
print(
|
||||
f"Extracted {unique_repos} GitHub URLs, "
|
||||
f"{len(papers)} papers, {len(keywords)} keywords "
|
||||
f"-> {output_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search cloned repo for specific implementations (classes, functions, algorithms).
|
||||
|
||||
Usage:
|
||||
python find_implementations.py --repo-dir ./repos/owner_name --patterns "class Transformer" "def train" --output matches.jsonl
|
||||
python find_implementations.py --repo-dir ./repos/owner_name --keywords "attention" "embedding" --output matches.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SKIP_DIRS: frozenset[str] = frozenset({
|
||||
".git", "node_modules", "__pycache__", ".venv", "venv",
|
||||
".tox", ".mypy_cache", ".pytest_cache", ".eggs", "dist", "build",
|
||||
".next", ".nuxt",
|
||||
})
|
||||
|
||||
TEST_INDICATORS: tuple[str, ...] = (
|
||||
"/test/", "/tests/", "test_", "_test.py", ".test.", ".spec.",
|
||||
)
|
||||
|
||||
MAX_FILE_SIZE: int = 1_048_576 # 1 MB
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_binary(filepath: Path) -> bool:
|
||||
"""Check if a file is likely binary by reading the first 8 KB."""
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
chunk = f.read(8192)
|
||||
return b"\x00" in chunk
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def is_test_path(rel_path: str) -> bool:
|
||||
"""Return True if the relative path looks like a test file."""
|
||||
rel_lower = rel_path.lower()
|
||||
for indicator in TEST_INDICATORS:
|
||||
if indicator in rel_lower:
|
||||
return True
|
||||
# Also check basename prefix
|
||||
basename = os.path.basename(rel_lower)
|
||||
if basename.startswith("test_"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_pattern(pattern: str) -> tuple[str, re.Pattern[str]]:
|
||||
"""Classify a search pattern and return (match_type, compiled_regex).
|
||||
|
||||
Patterns starting with "class " or "def " get special match types.
|
||||
Other patterns are compiled as-is (user-supplied regex).
|
||||
"""
|
||||
if pattern.startswith("class "):
|
||||
name = pattern[6:].strip()
|
||||
return "class", re.compile(r"class\s+" + re.escape(name))
|
||||
elif pattern.startswith("def "):
|
||||
name = pattern[4:].strip()
|
||||
return "function", re.compile(r"def\s+" + re.escape(name))
|
||||
else:
|
||||
try:
|
||||
return "keyword", re.compile(pattern)
|
||||
except re.error:
|
||||
# Fall back to escaped literal if pattern is not valid regex
|
||||
return "keyword", re.compile(re.escape(pattern))
|
||||
|
||||
|
||||
def read_lines(filepath: Path) -> list[str] | None:
|
||||
"""Read all lines from a text file. Returns None on decode failure."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="strict") as f:
|
||||
return f.readlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def search_file(
|
||||
filepath: Path,
|
||||
rel_path: str,
|
||||
patterns: list[tuple[str, re.Pattern[str]]],
|
||||
keywords: list[str],
|
||||
context: int,
|
||||
repo_id: str,
|
||||
) -> list[dict]:
|
||||
"""Search a single file for patterns and keywords. Returns match dicts."""
|
||||
lines = read_lines(filepath)
|
||||
if lines is None:
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
# --- Pattern search (regex) ---
|
||||
for match_type, regex in patterns:
|
||||
for i, line in enumerate(lines):
|
||||
if regex.search(line):
|
||||
start = max(0, i - context)
|
||||
end = min(len(lines), i + context + 1)
|
||||
results.append({
|
||||
"repo_id": repo_id,
|
||||
"file_path": rel_path,
|
||||
"line_number": i + 1,
|
||||
"match_type": match_type,
|
||||
"matched_text": line.rstrip("\n"),
|
||||
"context_before": [
|
||||
l.rstrip("\n") for l in lines[start:i]
|
||||
],
|
||||
"context_after": [
|
||||
l.rstrip("\n") for l in lines[i + 1:end]
|
||||
],
|
||||
})
|
||||
|
||||
# --- Keyword search (case-insensitive substring) ---
|
||||
for kw in keywords:
|
||||
kw_lower = kw.lower()
|
||||
for i, line in enumerate(lines):
|
||||
if kw_lower in line.lower():
|
||||
start = max(0, i - context)
|
||||
end = min(len(lines), i + context + 1)
|
||||
results.append({
|
||||
"repo_id": repo_id,
|
||||
"file_path": rel_path,
|
||||
"line_number": i + 1,
|
||||
"match_type": "keyword",
|
||||
"matched_text": line.rstrip("\n"),
|
||||
"context_before": [
|
||||
l.rstrip("\n") for l in lines[start:i]
|
||||
],
|
||||
"context_after": [
|
||||
l.rstrip("\n") for l in lines[i + 1:end]
|
||||
],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search cloned repo for specific implementations "
|
||||
"(classes, functions, algorithms)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-dir", required=True,
|
||||
help="Path to the cloned repository",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--patterns", nargs="+", default=None,
|
||||
help='Regex patterns (e.g. "class Transformer" "def train")',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keywords", nargs="+", default=None,
|
||||
help="Simple keyword search terms (case-insensitive substring match)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", required=True,
|
||||
help="Output JSONL file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context", type=int, default=5,
|
||||
help="Lines of context before and after each match (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-tests", action="store_true",
|
||||
help="Skip test files and test directories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-id", default=None,
|
||||
help="Repository identifier (owner/name); inferred from dir name if omitted",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.patterns and not args.keywords:
|
||||
parser.error("Provide at least one of --patterns or --keywords")
|
||||
|
||||
repo_dir = Path(args.repo_dir).resolve()
|
||||
if not repo_dir.is_dir():
|
||||
print(f"Error: repo directory not found: {repo_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Infer repo_id
|
||||
repo_id: str = args.repo_id or ""
|
||||
if not repo_id:
|
||||
dir_name = repo_dir.name
|
||||
repo_id = dir_name.replace("_", "/", 1) if "_" in dir_name else dir_name
|
||||
|
||||
# Classify patterns
|
||||
classified_patterns: list[tuple[str, re.Pattern[str]]] = []
|
||||
if args.patterns:
|
||||
for p in args.patterns:
|
||||
match_type, regex = classify_pattern(p)
|
||||
classified_patterns.append((match_type, regex))
|
||||
print(f" Pattern: '{p}' -> type={match_type}", file=sys.stderr)
|
||||
|
||||
keyword_list: list[str] = args.keywords or []
|
||||
if keyword_list:
|
||||
print(f" Keywords: {keyword_list}", file=sys.stderr)
|
||||
|
||||
# Walk and search
|
||||
total_matches = 0
|
||||
files_with_matches = 0
|
||||
files_scanned = 0
|
||||
files_skipped = 0
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Searching {repo_dir} ...", file=sys.stderr)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as out_f:
|
||||
for dirpath, dirnames, filenames in os.walk(repo_dir):
|
||||
# Filter directories in-place to prune traversal
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
|
||||
for fname in filenames:
|
||||
filepath = Path(dirpath) / fname
|
||||
rel_path = str(filepath.relative_to(repo_dir)).replace(os.sep, "/")
|
||||
|
||||
# Skip test files if requested
|
||||
if args.skip_tests and is_test_path(rel_path):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip files that are too large
|
||||
try:
|
||||
size = filepath.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
if size > MAX_FILE_SIZE:
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip binary files
|
||||
if is_binary(filepath):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
files_scanned += 1
|
||||
matches = search_file(
|
||||
filepath, rel_path,
|
||||
classified_patterns, keyword_list,
|
||||
args.context, repo_id,
|
||||
)
|
||||
|
||||
if matches:
|
||||
files_with_matches += 1
|
||||
for m in matches:
|
||||
out_f.write(json.dumps(m, ensure_ascii=False) + "\n")
|
||||
total_matches += 1
|
||||
|
||||
print(
|
||||
f"Found {total_matches} matches across {files_with_matches} files "
|
||||
f"({files_scanned} scanned, {files_skipped} skipped) -> {args.output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+470
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JSONL GitHub repo database management.
|
||||
|
||||
Subcommands: merge, filter, score, search, tag, stats, export, rank.
|
||||
Deduplication by exact repo_id (owner/name) match.
|
||||
|
||||
Usage:
|
||||
python repo_db.py merge --inputs a.jsonl b.jsonl --output merged.jsonl
|
||||
python repo_db.py filter --input db.jsonl --output filtered.jsonl --min-stars 100
|
||||
python repo_db.py score --input db.jsonl --output scored.jsonl
|
||||
python repo_db.py search --input db.jsonl --query "transformer"
|
||||
python repo_db.py tag --input db.jsonl --ids owner/name --tags impl baseline
|
||||
python repo_db.py stats --input db.jsonl
|
||||
python repo_db.py export --input db.jsonl --format markdown
|
||||
python repo_db.py rank --input db.jsonl --output ranked.jsonl --by composite_score
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# -- I/O helpers --------------------------------------------------------------
|
||||
|
||||
def load_jsonl(path: str) -> list[dict]:
|
||||
"""Load records from a JSONL file."""
|
||||
records = []
|
||||
if not os.path.exists(path):
|
||||
return records
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
records.append(json.loads(line))
|
||||
return records
|
||||
|
||||
|
||||
def save_jsonl(records: list[dict], path: str):
|
||||
"""Save records to a JSONL file."""
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for rec in records:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def deduplicate(records: list[dict]) -> list[dict]:
|
||||
"""Remove duplicate repos by exact repo_id match. Later entries win."""
|
||||
seen: dict[str, int] = {}
|
||||
for i, rec in enumerate(records):
|
||||
rid = rec.get("repo_id", "")
|
||||
if rid:
|
||||
seen[rid] = i
|
||||
# Preserve order of last occurrence
|
||||
indices = sorted(seen.values())
|
||||
return [records[i] for i in indices]
|
||||
|
||||
|
||||
# -- Scoring ------------------------------------------------------------------
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
"""Sigmoid with steepness 5 centered at 0.5."""
|
||||
return 1.0 / (1.0 + math.exp(-5.0 * (x - 0.5)))
|
||||
|
||||
|
||||
def _parse_iso(dt_str: str | None) -> datetime | None:
|
||||
"""Parse an ISO-8601 datetime string."""
|
||||
if not dt_str:
|
||||
return None
|
||||
# Handle trailing Z and optional fractional seconds
|
||||
dt_str = dt_str.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(dt_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def compute_activity_score(rec: dict, now: datetime) -> float:
|
||||
"""Compute activity score in [0, 1]."""
|
||||
pushed = _parse_iso(rec.get("pushed_at"))
|
||||
if pushed is None:
|
||||
return _sigmoid(0.0)
|
||||
|
||||
days_since_push = max((now - pushed).days, 0)
|
||||
recent_push = 1.0 if days_since_push < 90 else 0.0
|
||||
has_recent_commits = 1.0 if days_since_push < 180 else 0.0
|
||||
|
||||
stars = max(rec.get("stars", 0), 1)
|
||||
open_issues = rec.get("open_issues", 0)
|
||||
ratio = min(open_issues / stars, 1.0)
|
||||
issues_component = 1.0 - ratio # lower ratio is better
|
||||
|
||||
raw = recent_push * 0.4 + has_recent_commits * 0.3 + issues_component * 0.3
|
||||
return _sigmoid(raw)
|
||||
|
||||
|
||||
def compute_quality_score(rec: dict) -> float:
|
||||
"""Compute quality score in [0, 1]."""
|
||||
stars = rec.get("stars", 0)
|
||||
forks = rec.get("forks", 0)
|
||||
|
||||
# Normalize log components: log(x+1) / log(100001) gives ~0-1 for 0-100k
|
||||
max_log = math.log(100001)
|
||||
star_comp = min(math.log(stars + 1) / max_log, 1.0) * 0.3
|
||||
fork_comp = min(math.log(forks + 1) / max_log, 1.0) * 0.2
|
||||
|
||||
has_license = 0.15 if rec.get("license") else 0.0
|
||||
has_readme = 0.15 if rec.get("readme_excerpt") else 0.0
|
||||
not_archived = 0.2 if not rec.get("archived", False) else 0.0
|
||||
|
||||
raw = star_comp + fork_comp + has_license + has_readme + not_archived
|
||||
return min(raw, 1.0)
|
||||
|
||||
|
||||
def score_record(rec: dict, now: datetime) -> dict:
|
||||
"""Compute all scores for a single record and return updated copy."""
|
||||
rec = dict(rec)
|
||||
rec["activity_score"] = round(compute_activity_score(rec, now), 4)
|
||||
rec["quality_score"] = round(compute_quality_score(rec), 4)
|
||||
|
||||
relevance = rec.get("relevance_score", 0.0) or 0.0
|
||||
quality = rec["quality_score"]
|
||||
activity = rec["activity_score"]
|
||||
rec["composite_score"] = round(
|
||||
relevance * 0.4 + quality * 0.35 + activity * 0.25, 4
|
||||
)
|
||||
return rec
|
||||
|
||||
|
||||
# -- Subcommand implementations -----------------------------------------------
|
||||
|
||||
def cmd_merge(args):
|
||||
"""Merge multiple JSONL files with deduplication by repo_id."""
|
||||
all_records = []
|
||||
for path in args.inputs:
|
||||
records = load_jsonl(path)
|
||||
print(f"Loaded {len(records)} from {path}", file=sys.stderr)
|
||||
all_records.extend(records)
|
||||
|
||||
merged = deduplicate(all_records)
|
||||
save_jsonl(merged, args.output)
|
||||
print(f"Merged: {len(all_records)} -> {len(merged)} unique repos -> {args.output}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_filter(args):
|
||||
"""Filter repos by various criteria."""
|
||||
records = load_jsonl(args.input)
|
||||
kept = []
|
||||
|
||||
for rec in records:
|
||||
if args.min_stars is not None and rec.get("stars", 0) < args.min_stars:
|
||||
continue
|
||||
if args.min_score is not None and rec.get("composite_score", 0.0) < args.min_score:
|
||||
continue
|
||||
if args.language and rec.get("language", "").lower() != args.language.lower():
|
||||
continue
|
||||
if args.not_archived and rec.get("archived", False):
|
||||
continue
|
||||
kept.append(rec)
|
||||
|
||||
# Sort by composite_score descending
|
||||
kept.sort(key=lambda r: -(r.get("composite_score", 0.0) or 0.0))
|
||||
|
||||
if args.max_repos and args.max_repos > 0 and len(kept) > args.max_repos:
|
||||
kept = kept[:args.max_repos]
|
||||
|
||||
save_jsonl(kept, args.output)
|
||||
print(f"Filtered: {len(records)} -> {len(kept)} repos -> {args.output}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_score(args):
|
||||
"""Compute composite scores for all repos."""
|
||||
records = load_jsonl(args.input)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
scored = [score_record(rec, now) for rec in records]
|
||||
save_jsonl(scored, args.output)
|
||||
print(f"Scored {len(scored)} repos -> {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
"""Search repos by keyword match in a field."""
|
||||
records = load_jsonl(args.input)
|
||||
query_lower = args.query.lower()
|
||||
results = []
|
||||
|
||||
for rec in records:
|
||||
value = rec.get(args.field, "")
|
||||
if isinstance(value, list):
|
||||
value = " ".join(str(v) for v in value)
|
||||
if query_lower in str(value).lower():
|
||||
results.append(rec)
|
||||
|
||||
for rec in results:
|
||||
print(json.dumps(rec, ensure_ascii=False))
|
||||
print(f"Found {len(results)} matches", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_tag(args):
|
||||
"""Add tags to specific repos. Supports 'relevance:0.85' format."""
|
||||
records = load_jsonl(args.input)
|
||||
id_set = set(args.ids)
|
||||
tagged = 0
|
||||
|
||||
# Separate relevance assignments from plain tags
|
||||
plain_tags = []
|
||||
relevance_val = None
|
||||
for t in args.tags:
|
||||
if t.startswith("relevance:"):
|
||||
try:
|
||||
relevance_val = float(t.split(":", 1)[1])
|
||||
except ValueError:
|
||||
plain_tags.append(t)
|
||||
else:
|
||||
plain_tags.append(t)
|
||||
|
||||
for rec in records:
|
||||
rid = rec.get("repo_id", "")
|
||||
if rid in id_set:
|
||||
if plain_tags:
|
||||
existing = rec.get("tags", [])
|
||||
rec["tags"] = sorted(set(existing + plain_tags))
|
||||
if relevance_val is not None:
|
||||
rec["relevance_score"] = relevance_val
|
||||
tagged += 1
|
||||
|
||||
save_jsonl(records, args.input)
|
||||
msg_parts = []
|
||||
if plain_tags:
|
||||
msg_parts.append(f"tags={plain_tags}")
|
||||
if relevance_val is not None:
|
||||
msg_parts.append(f"relevance_score={relevance_val}")
|
||||
print(f"Tagged {tagged} repos with {', '.join(msg_parts)}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_stats(args):
|
||||
"""Compute and print JSON summary statistics."""
|
||||
records = load_jsonl(args.input)
|
||||
if not records:
|
||||
print(json.dumps({"total": 0}, indent=2))
|
||||
return
|
||||
|
||||
languages: dict[str, int] = {}
|
||||
sources: dict[str, int] = {}
|
||||
tags_dist: dict[str, int] = {}
|
||||
total_stars = 0
|
||||
total_forks = 0
|
||||
archived_count = 0
|
||||
with_readme = 0
|
||||
with_papers = 0
|
||||
scored_count = 0
|
||||
score_sum = 0.0
|
||||
|
||||
for rec in records:
|
||||
lang = rec.get("language") or "Unknown"
|
||||
languages[lang] = languages.get(lang, 0) + 1
|
||||
|
||||
src = rec.get("source", "unknown")
|
||||
sources[src] = sources.get(src, 0) + 1
|
||||
|
||||
total_stars += rec.get("stars", 0)
|
||||
total_forks += rec.get("forks", 0)
|
||||
|
||||
if rec.get("archived"):
|
||||
archived_count += 1
|
||||
if rec.get("readme_excerpt"):
|
||||
with_readme += 1
|
||||
if rec.get("paper_ids"):
|
||||
with_papers += 1
|
||||
|
||||
cs = rec.get("composite_score", 0.0) or 0.0
|
||||
if cs > 0:
|
||||
scored_count += 1
|
||||
score_sum += cs
|
||||
|
||||
for tag in rec.get("tags", []):
|
||||
tags_dist[tag] = tags_dist.get(tag, 0) + 1
|
||||
|
||||
stats = {
|
||||
"total": len(records),
|
||||
"archived": archived_count,
|
||||
"with_readme": with_readme,
|
||||
"with_papers": with_papers,
|
||||
"total_stars": total_stars,
|
||||
"total_forks": total_forks,
|
||||
"avg_stars": round(total_stars / len(records), 1),
|
||||
"avg_composite_score": round(score_sum / scored_count, 4) if scored_count else 0.0,
|
||||
"languages": dict(sorted(languages.items(), key=lambda x: -x[1])[:15]),
|
||||
"sources": sources,
|
||||
"tags": tags_dist,
|
||||
}
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
"""Export database in csv, jsonl, or markdown format."""
|
||||
records = load_jsonl(args.input)
|
||||
if not records:
|
||||
print("No records to export.", file=sys.stderr)
|
||||
return
|
||||
|
||||
fmt = args.format
|
||||
|
||||
if fmt == "csv":
|
||||
output = _export_csv(records)
|
||||
elif fmt == "jsonl":
|
||||
output = "\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n"
|
||||
elif fmt == "markdown":
|
||||
output = _export_markdown(records)
|
||||
else:
|
||||
print(f"Unknown format: {fmt}", file=sys.stderr)
|
||||
return
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
print(f"Exported {len(records)} repos to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output, end="")
|
||||
|
||||
|
||||
def _export_csv(records: list[dict]) -> str:
|
||||
fields = [
|
||||
"repo_id", "name", "owner", "stars", "forks", "language", "license",
|
||||
"composite_score", "quality_score", "activity_score", "relevance_score",
|
||||
"topics", "tags", "archived", "source",
|
||||
]
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for rec in records:
|
||||
row = dict(rec)
|
||||
if isinstance(row.get("topics"), list):
|
||||
row["topics"] = "; ".join(row["topics"])
|
||||
if isinstance(row.get("tags"), list):
|
||||
row["tags"] = "; ".join(row["tags"])
|
||||
writer.writerow(row)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _export_markdown(records: list[dict]) -> str:
|
||||
lines = ["| Repo | Stars | Language | Score | Description |",
|
||||
"|------|------:|----------|------:|-------------|"]
|
||||
for rec in records:
|
||||
rid = rec.get("repo_id", "")
|
||||
url = rec.get("url", f"https://github.com/{rid}")
|
||||
stars = rec.get("stars", 0)
|
||||
lang = rec.get("language", "")
|
||||
score = rec.get("composite_score", 0.0) or 0.0
|
||||
desc = (rec.get("description") or "")[:80]
|
||||
desc = desc.replace("|", "\\|")
|
||||
lines.append(f"| [{rid}]({url}) | {stars} | {lang} | {score:.3f} | {desc} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def cmd_rank(args):
|
||||
"""Rank repos by a given field descending."""
|
||||
records = load_jsonl(args.input)
|
||||
# Map CLI choice to actual record field name
|
||||
field_map = {
|
||||
"composite_score": "composite_score",
|
||||
"stars": "stars",
|
||||
"updated": "updated_at",
|
||||
}
|
||||
field = field_map.get(args.by, args.by)
|
||||
|
||||
def sort_key(r):
|
||||
val = r.get(field, 0)
|
||||
if val is None:
|
||||
return ""
|
||||
# For date strings, lexicographic sort works with ISO format
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
return val
|
||||
|
||||
records.sort(key=sort_key, reverse=True)
|
||||
|
||||
for i, rec in enumerate(records):
|
||||
rec["rank"] = i + 1
|
||||
|
||||
save_jsonl(records, args.output)
|
||||
print(f"Ranked {len(records)} repos by {field} -> {args.output}", file=sys.stderr)
|
||||
|
||||
|
||||
# -- CLI -----------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="JSONL GitHub repo database management tool"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# merge
|
||||
p = sub.add_parser("merge", help="Merge multiple JSONL files with dedup by repo_id")
|
||||
p.add_argument("--inputs", nargs="+", required=True, help="Input JSONL files")
|
||||
p.add_argument("--output", required=True, help="Output JSONL file")
|
||||
|
||||
# filter
|
||||
p = sub.add_parser("filter", help="Filter repos by stars, score, language, etc.")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
p.add_argument("--output", required=True, help="Output JSONL file")
|
||||
p.add_argument("--min-stars", type=int, default=None, help="Minimum star count")
|
||||
p.add_argument("--min-score", type=float, default=None, help="Minimum composite score")
|
||||
p.add_argument("--max-repos", type=int, default=None, help="Max repos to keep (0=unlimited)")
|
||||
p.add_argument("--language", default=None, help="Filter by primary language")
|
||||
p.add_argument("--not-archived", action="store_true", help="Exclude archived repos")
|
||||
|
||||
# score
|
||||
p = sub.add_parser("score", help="Compute composite scores for all repos")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
p.add_argument("--output", required=True, help="Output scored JSONL file")
|
||||
|
||||
# search
|
||||
p = sub.add_parser("search", help="Search repos by keyword in a field")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
p.add_argument("--query", required=True, help="Search query string")
|
||||
p.add_argument("--field", default="description",
|
||||
choices=["description", "topics", "name"],
|
||||
help="Field to search (default: description)")
|
||||
|
||||
# tag
|
||||
p = sub.add_parser("tag", help="Add tags to repos; supports 'relevance:0.85' format")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file (modified in-place)")
|
||||
p.add_argument("--ids", nargs="+", required=True, help="Repo IDs (owner/name)")
|
||||
p.add_argument("--tags", nargs="+", required=True,
|
||||
help="Tags to add; use 'relevance:0.85' to set relevance_score")
|
||||
|
||||
# stats
|
||||
p = sub.add_parser("stats", help="Print JSON summary statistics")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
|
||||
# export
|
||||
p = sub.add_parser("export", help="Export database to csv, jsonl, or markdown")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
p.add_argument("--format", choices=["csv", "jsonl", "markdown"], default="jsonl",
|
||||
help="Output format (default: jsonl)")
|
||||
p.add_argument("--output", "-o", default=None, help="Output file (default: stdout)")
|
||||
|
||||
# rank
|
||||
p = sub.add_parser("rank", help="Rank repos by a field descending")
|
||||
p.add_argument("--input", required=True, help="Input JSONL file")
|
||||
p.add_argument("--output", required=True, help="Output ranked JSONL file")
|
||||
p.add_argument("--by", default="composite_score",
|
||||
choices=["composite_score", "stars", "updated"],
|
||||
help="Field to rank by (default: composite_score)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dispatch = {
|
||||
"merge": cmd_merge,
|
||||
"filter": cmd_filter,
|
||||
"score": cmd_score,
|
||||
"search": cmd_search,
|
||||
"tag": cmd_tag,
|
||||
"stats": cmd_stats,
|
||||
"export": cmd_export,
|
||||
"rank": cmd_rank,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch detailed metadata for GitHub repos via ``gh api``.
|
||||
|
||||
Retrieves main repo info and language breakdown, then maps the response
|
||||
to the repo_db schema. Can enrich existing JSONL records or create new
|
||||
ones from scratch.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def gh_api(endpoint: str) -> dict | None:
|
||||
"""Call ``gh api`` and return parsed JSON, or *None* on failure."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["gh", "api", endpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return json.loads(proc.stdout)
|
||||
stderr = proc.stderr.strip()
|
||||
if "404" in stderr or "Not Found" in stderr:
|
||||
print(f" 404 — repo not found", file=sys.stderr)
|
||||
elif "rate limit" in stderr.lower() or "403" in stderr:
|
||||
print(f" Rate limited. Consider increasing --delay.", file=sys.stderr)
|
||||
else:
|
||||
print(f" gh api error ({proc.returncode}): {stderr[:120]}", file=sys.stderr)
|
||||
except FileNotFoundError:
|
||||
print("Error: 'gh' CLI not found. Please install GitHub CLI.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
||||
print(f" gh api exception: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Language breakdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_languages_pct(lang_data: dict) -> dict[str, float]:
|
||||
"""Convert raw byte counts to percentages (0–100)."""
|
||||
total = sum(lang_data.values())
|
||||
if total == 0:
|
||||
return {}
|
||||
return {lang: round(count / total * 100, 2) for lang, count in lang_data.items()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record building
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_record(repo_id: str, meta: dict, lang_data: dict) -> dict:
|
||||
"""Build a repo_db-schema record from GitHub API responses."""
|
||||
license_info = meta.get("license") or {}
|
||||
languages_pct = compute_languages_pct(lang_data)
|
||||
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"github_url": meta.get("html_url", f"https://github.com/{repo_id}"),
|
||||
"description": meta.get("description", ""),
|
||||
"homepage": meta.get("homepage", ""),
|
||||
"stars": meta.get("stargazers_count", 0),
|
||||
"forks_count": meta.get("forks_count", 0),
|
||||
"open_issues_count": meta.get("open_issues_count", 0),
|
||||
"watchers_count": meta.get("watchers_count", 0),
|
||||
"default_branch": meta.get("default_branch", ""),
|
||||
"license": license_info.get("spdx_id") or license_info.get("name", ""),
|
||||
"language": meta.get("language", ""),
|
||||
"languages_pct": languages_pct,
|
||||
"topics": meta.get("topics", []),
|
||||
"is_fork": meta.get("fork", False),
|
||||
"is_archived": meta.get("archived", False),
|
||||
"size_kb": meta.get("size", 0),
|
||||
"created_at": meta.get("created_at", ""),
|
||||
"updated_at": meta.get("updated_at", ""),
|
||||
"pushed_at": meta.get("pushed_at", ""),
|
||||
"owner_type": (meta.get("owner") or {}).get("type", ""),
|
||||
"network_count": meta.get("network_count", 0),
|
||||
"subscribers_count": meta.get("subscribers_count", 0),
|
||||
}
|
||||
|
||||
|
||||
def enrich_record(existing: dict, fresh: dict) -> dict:
|
||||
"""Merge *fresh* metadata into an *existing* record.
|
||||
|
||||
Fresh values overwrite existing ones, except we preserve fields
|
||||
that exist only in the original (e.g. ``source``, ``paper_ids``).
|
||||
"""
|
||||
merged = dict(existing)
|
||||
merged.update(fresh)
|
||||
# Preserve provenance fields from the original record.
|
||||
for key in ("source", "paper_ids", "paper_titles", "is_official", "framework"):
|
||||
if key in existing:
|
||||
merged[key] = existing[key]
|
||||
return merged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I/O helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_jsonl(path: str) -> list[dict]:
|
||||
"""Load a JSONL file into a list of dicts."""
|
||||
records: list[dict] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def write_jsonl(records: list[dict], path: str) -> None:
|
||||
"""Write a list of dicts as JSONL."""
|
||||
with open(path, "w", encoding="utf-8") as fout:
|
||||
for rec in records:
|
||||
fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch detailed GitHub repo metadata via gh api.",
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--repos", nargs="+", metavar="OWNER/NAME",
|
||||
help="One or more repos as owner/name")
|
||||
source.add_argument("--input", metavar="FILE",
|
||||
help="JSONL file with existing repo records to enrich")
|
||||
parser.add_argument("--output", required=True, help="Output JSONL file path")
|
||||
parser.add_argument("--delay", type=float, default=0.5,
|
||||
help="Seconds between API requests per repo (default: 0.5)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine which repos to process.
|
||||
existing_records: dict[str, dict] = {} # keyed by repo_id
|
||||
|
||||
if args.input:
|
||||
for rec in load_jsonl(args.input):
|
||||
rid = rec.get("repo_id")
|
||||
if rid:
|
||||
existing_records[rid] = rec
|
||||
repo_ids = list(existing_records.keys())
|
||||
else:
|
||||
repo_ids = args.repos or []
|
||||
|
||||
if not repo_ids:
|
||||
print("No repos to process.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
total = len(repo_ids)
|
||||
results: list[dict] = []
|
||||
|
||||
for idx, repo_id in enumerate(repo_ids, 1):
|
||||
print(f"Fetching metadata: {idx}/{total} — {repo_id}", file=sys.stderr)
|
||||
|
||||
meta = gh_api(f"/repos/{repo_id}")
|
||||
if meta is None:
|
||||
print(f" Skipping {repo_id} (metadata unavailable)", file=sys.stderr)
|
||||
# Keep the original record if enriching.
|
||||
if repo_id in existing_records:
|
||||
results.append(existing_records[repo_id])
|
||||
continue
|
||||
|
||||
time.sleep(args.delay)
|
||||
|
||||
lang_data = gh_api(f"/repos/{repo_id}/languages") or {}
|
||||
|
||||
fresh = build_record(repo_id, meta, lang_data)
|
||||
|
||||
if repo_id in existing_records:
|
||||
record = enrich_record(existing_records[repo_id], fresh)
|
||||
else:
|
||||
record = fresh
|
||||
|
||||
results.append(record)
|
||||
|
||||
if idx < total:
|
||||
time.sleep(args.delay)
|
||||
|
||||
write_jsonl(results, args.output)
|
||||
print(f"Done. {len(results)}/{total} record(s) written to {args.output}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch README content for GitHub repos without cloning.
|
||||
|
||||
Uses ``gh api`` to retrieve the README of one or more repositories,
|
||||
decodes the base64 payload, and writes the results as JSONL.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def gh_api(endpoint: str) -> dict | None:
|
||||
"""Call ``gh api`` and return parsed JSON, or *None* on failure."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["gh", "api", endpoint],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return json.loads(proc.stdout)
|
||||
# Distinguish 404 (no README) from other errors.
|
||||
if "404" in proc.stderr or "Not Found" in proc.stderr:
|
||||
return None
|
||||
print(f" gh api error ({proc.returncode}): {proc.stderr.strip()[:120]}",
|
||||
file=sys.stderr)
|
||||
except FileNotFoundError:
|
||||
print(" Error: 'gh' CLI not found. Please install GitHub CLI.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
||||
print(f" gh api exception: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_readme(repo_id: str, max_chars: int) -> dict | None:
|
||||
"""Return a dict with repo_id, readme_text, readme_length or *None*."""
|
||||
data = gh_api(f"/repos/{repo_id}/readme")
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
content_b64 = data.get("content", "")
|
||||
try:
|
||||
raw = base64.b64decode(content_b64).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
truncated = raw[:max_chars]
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"readme_text": truncated,
|
||||
"readme_length": len(raw),
|
||||
}
|
||||
|
||||
|
||||
def load_repo_ids_from_jsonl(path: str) -> list[str]:
|
||||
"""Read repo_id values from a JSONL file."""
|
||||
ids: list[str] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
rid = obj.get("repo_id")
|
||||
if rid:
|
||||
ids.append(rid)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch README content for GitHub repos without cloning.",
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--repos", nargs="+", metavar="OWNER/NAME",
|
||||
help="One or more repos as owner/name")
|
||||
source.add_argument("--input", metavar="FILE",
|
||||
help="JSONL file with repo_id fields")
|
||||
parser.add_argument("--output", required=True, help="Output JSONL file path")
|
||||
parser.add_argument("--max-chars", type=int, default=5000,
|
||||
help="Max characters to keep from README (default: 5000)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_ids: list[str] = args.repos if args.repos else load_repo_ids_from_jsonl(args.input)
|
||||
|
||||
if not repo_ids:
|
||||
print("No repos to process.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
total = len(repo_ids)
|
||||
results: list[dict] = []
|
||||
|
||||
for idx, repo_id in enumerate(repo_ids, 1):
|
||||
print(f"Fetching README: {idx}/{total} — {repo_id}", file=sys.stderr)
|
||||
record = fetch_readme(repo_id, max_chars=args.max_chars)
|
||||
if record:
|
||||
results.append(record)
|
||||
else:
|
||||
print(f" No README found for {repo_id}", file=sys.stderr)
|
||||
if idx < total:
|
||||
time.sleep(0.5)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as fout:
|
||||
for rec in results:
|
||||
fout.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
print(f"Done. {len(results)}/{total} README(s) written to {args.output}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search GitHub repositories via `gh api` with multiple query strategies.
|
||||
|
||||
Builds qualified search queries and paginates through results, outputting
|
||||
repo_db-compatible JSONL records.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RESULTS_PER_PAGE = 30
|
||||
DEFAULT_MAX_RESULTS = 100
|
||||
|
||||
|
||||
def check_gh_installed():
|
||||
"""Verify gh CLI is available."""
|
||||
if not shutil.which("gh"):
|
||||
print("[error] gh CLI not found. Install it: https://cli.github.com/", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def gh_api(endpoint: str, params: dict | None = None) -> dict:
|
||||
"""Call gh api and return parsed JSON."""
|
||||
if params:
|
||||
from urllib.parse import urlencode
|
||||
endpoint = f"{endpoint}?{urlencode(params)}"
|
||||
cmd = ["gh", "api", endpoint]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[error] gh CLI not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[error] gh api call timed out", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
# Check for rate limit
|
||||
if "rate limit" in stderr.lower() or "403" in stderr:
|
||||
print(f"[error] GitHub API rate limit hit: {stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"[error] gh api failed (exit {result.returncode}): {stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[error] invalid JSON from gh api: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build_query(query: str, language: str | None, min_stars: int | None, topic: str | None) -> str:
|
||||
"""Build GitHub search query string with qualifiers."""
|
||||
parts = [query]
|
||||
if language:
|
||||
parts.append(f"language:{language}")
|
||||
if min_stars is not None and min_stars > 0:
|
||||
parts.append(f"stars:>={min_stars}")
|
||||
if topic:
|
||||
parts.append(f"topic:{topic}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def extract_license(license_obj) -> str:
|
||||
"""Extract license identifier from GitHub API license object."""
|
||||
if not license_obj or not isinstance(license_obj, dict):
|
||||
return ""
|
||||
return license_obj.get("spdx_id") or license_obj.get("name") or ""
|
||||
|
||||
|
||||
def map_repo(item: dict) -> dict:
|
||||
"""Map a GitHub API repository item to repo_db schema."""
|
||||
owner_obj = item.get("owner") or {}
|
||||
return {
|
||||
"repo_id": item.get("full_name", ""),
|
||||
"url": item.get("html_url", ""),
|
||||
"name": item.get("name", ""),
|
||||
"owner": owner_obj.get("login", ""),
|
||||
"description": item.get("description") or "",
|
||||
"stars": item.get("stargazers_count", 0),
|
||||
"forks": item.get("forks_count", 0),
|
||||
"language": item.get("language") or "",
|
||||
"license": extract_license(item.get("license")),
|
||||
"topics": item.get("topics", []),
|
||||
"created_at": item.get("created_at", ""),
|
||||
"updated_at": item.get("updated_at", ""),
|
||||
"pushed_at": item.get("pushed_at", ""),
|
||||
"open_issues": item.get("open_issues_count", 0),
|
||||
"default_branch": item.get("default_branch", "main"),
|
||||
"archived": item.get("archived", False),
|
||||
"source": "search_github",
|
||||
# Default fields for repo_db compatibility
|
||||
"languages_pct": {},
|
||||
"readme_excerpt": "",
|
||||
"paper_ids": [],
|
||||
"paper_titles": [],
|
||||
"relevance_score": 0.0,
|
||||
"quality_score": 0.0,
|
||||
"activity_score": 0.0,
|
||||
"composite_score": 0.0,
|
||||
"tags": [],
|
||||
"analyzed": False,
|
||||
"local_path": None,
|
||||
}
|
||||
|
||||
|
||||
def check_rate_limit():
|
||||
"""Check GitHub API rate limit and warn if approaching it."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "api", "rate_limit"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
search = data.get("resources", {}).get("search", {})
|
||||
remaining = search.get("remaining", -1)
|
||||
limit = search.get("limit", -1)
|
||||
reset_ts = search.get("reset", 0)
|
||||
|
||||
if remaining >= 0:
|
||||
if remaining <= 3:
|
||||
reset_time = time.strftime("%H:%M:%S", time.localtime(reset_ts))
|
||||
print(
|
||||
f"[warn] GitHub search API rate limit: {remaining}/{limit} remaining, "
|
||||
f"resets at {reset_time}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"[info] rate limit: {remaining}/{limit} search requests remaining", file=sys.stderr)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
||||
pass # Non-critical, skip
|
||||
|
||||
|
||||
def search_repos(
|
||||
query: str,
|
||||
sort: str,
|
||||
max_results: int,
|
||||
) -> list[dict]:
|
||||
"""Search GitHub repositories with pagination."""
|
||||
total_pages = math.ceil(max_results / RESULTS_PER_PAGE)
|
||||
all_repos = []
|
||||
seen_ids = set()
|
||||
|
||||
for page in range(1, total_pages + 1):
|
||||
print(f"[info] fetching page {page}/{total_pages} ...", file=sys.stderr)
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"sort": sort,
|
||||
"order": "desc",
|
||||
"per_page": str(min(RESULTS_PER_PAGE, max_results - len(all_repos))),
|
||||
"page": str(page),
|
||||
}
|
||||
|
||||
data = gh_api("/search/repositories", params)
|
||||
|
||||
total_count = data.get("total_count", 0)
|
||||
if page == 1:
|
||||
print(f"[info] total matching repos: {total_count}", file=sys.stderr)
|
||||
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
print("[info] no more results", file=sys.stderr)
|
||||
break
|
||||
|
||||
for item in items:
|
||||
repo = map_repo(item)
|
||||
if repo["repo_id"] not in seen_ids:
|
||||
seen_ids.add(repo["repo_id"])
|
||||
all_repos.append(repo)
|
||||
|
||||
if len(all_repos) >= max_results:
|
||||
break
|
||||
|
||||
if len(all_repos) >= max_results:
|
||||
break
|
||||
|
||||
# Incomplete results means GitHub truncated
|
||||
if data.get("incomplete_results", False):
|
||||
print("[warn] GitHub returned incomplete results (query too broad?)", file=sys.stderr)
|
||||
|
||||
# Respect rate limit: sleep 2s between pages for search API
|
||||
if page < total_pages:
|
||||
time.sleep(2)
|
||||
|
||||
return all_repos
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search GitHub repositories via gh API with flexible query strategies."
|
||||
)
|
||||
parser.add_argument("--query", required=True, help="Search query string")
|
||||
parser.add_argument("--language", default=None, help="Filter by programming language")
|
||||
parser.add_argument("--min-stars", type=int, default=None, help="Minimum star count")
|
||||
parser.add_argument(
|
||||
"--sort",
|
||||
choices=["stars", "updated", "best-match"],
|
||||
default="best-match",
|
||||
help="Sort order (default: best-match)",
|
||||
)
|
||||
parser.add_argument("--max-results", type=int, default=DEFAULT_MAX_RESULTS, help="Max results to fetch (default: 100)")
|
||||
parser.add_argument("--topic", default=None, help="Filter by GitHub topic")
|
||||
parser.add_argument("--output", required=True, help="Output JSONL file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
check_gh_installed()
|
||||
|
||||
# Build the qualified query
|
||||
full_query = build_query(args.query, args.language, args.min_stars, args.topic)
|
||||
print(f"[info] search query: {full_query}", file=sys.stderr)
|
||||
print(f"[info] sort: {args.sort}, max: {args.max_results}", file=sys.stderr)
|
||||
|
||||
# Check rate limit before starting
|
||||
check_rate_limit()
|
||||
|
||||
# Handle "best-match" -> empty sort for GitHub API (relevance is default)
|
||||
sort_param = "" if args.sort == "best-match" else args.sort
|
||||
|
||||
repos = search_repos(full_query, sort_param, args.max_results)
|
||||
|
||||
# Write output
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for repo in repos:
|
||||
f.write(json.dumps(repo, ensure_ascii=False) + "\n")
|
||||
|
||||
# Summary stats
|
||||
print(f"\n[info] results summary:", file=sys.stderr)
|
||||
print(f" repos found: {len(repos)}", file=sys.stderr)
|
||||
if repos:
|
||||
languages = {}
|
||||
for r in repos:
|
||||
lang = r["language"] or "unknown"
|
||||
languages[lang] = languages.get(lang, 0) + 1
|
||||
top_langs = sorted(languages.items(), key=lambda x: -x[1])[:5]
|
||||
print(f" top languages: {', '.join(f'{l}({c})' for l, c in top_langs)}", file=sys.stderr)
|
||||
|
||||
star_counts = [r["stars"] for r in repos]
|
||||
print(f" stars range: {min(star_counts)} - {max(star_counts)}", file=sys.stderr)
|
||||
|
||||
print(f"[info] written to {output_path}", file=sys.stderr)
|
||||
|
||||
# Check rate limit after
|
||||
check_rate_limit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search GitHub code for specific function/class implementations via `gh api`.
|
||||
|
||||
Searches code using GitHub's code search API, groups results by repo, and
|
||||
outputs both code-level matches (stderr) and repo-level JSONL (--output).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RESULTS_PER_PAGE = 30
|
||||
DEFAULT_MAX_RESULTS = 50
|
||||
|
||||
|
||||
def check_gh_installed():
|
||||
"""Verify gh CLI is available."""
|
||||
if not shutil.which("gh"):
|
||||
print("[error] gh CLI not found. Install it: https://cli.github.com/", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def gh_api(endpoint: str, params: dict | None = None) -> dict:
|
||||
"""Call gh api and return parsed JSON."""
|
||||
if params:
|
||||
from urllib.parse import urlencode
|
||||
endpoint = f"{endpoint}?{urlencode(params)}"
|
||||
cmd = ["gh", "api", endpoint]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[error] gh CLI not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[error] gh api call timed out", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
if "rate limit" in stderr.lower() or "403" in stderr:
|
||||
print(f"[error] GitHub API rate limit hit: {stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"[error] gh api failed (exit {result.returncode}): {stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[error] invalid JSON from gh api: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build_code_query(query: str, language: str | None, filename: str | None) -> str:
|
||||
"""Build GitHub code search query with qualifiers."""
|
||||
parts = [query]
|
||||
if language:
|
||||
parts.append(f"language:{language}")
|
||||
if filename:
|
||||
parts.append(f"filename:{filename}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def search_code(query: str, max_results: int) -> list[dict]:
|
||||
"""Search GitHub code with pagination."""
|
||||
total_pages = math.ceil(max_results / RESULTS_PER_PAGE)
|
||||
all_items = []
|
||||
|
||||
for page in range(1, total_pages + 1):
|
||||
print(f"[info] fetching page {page}/{total_pages} ...", file=sys.stderr)
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"per_page": str(min(RESULTS_PER_PAGE, max_results - len(all_items))),
|
||||
"page": str(page),
|
||||
}
|
||||
|
||||
data = gh_api("/search/code", params)
|
||||
|
||||
total_count = data.get("total_count", 0)
|
||||
if page == 1:
|
||||
print(f"[info] total matching files: {total_count}", file=sys.stderr)
|
||||
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
print("[info] no more results", file=sys.stderr)
|
||||
break
|
||||
|
||||
all_items.extend(items)
|
||||
|
||||
if len(all_items) >= max_results:
|
||||
all_items = all_items[:max_results]
|
||||
break
|
||||
|
||||
if data.get("incomplete_results", False):
|
||||
print("[warn] GitHub returned incomplete results", file=sys.stderr)
|
||||
|
||||
# Code search is heavily rate limited: 10 req/min
|
||||
if page < total_pages:
|
||||
time.sleep(6)
|
||||
|
||||
return all_items
|
||||
|
||||
|
||||
def extract_code_match(item: dict) -> dict:
|
||||
"""Extract a code match record from a search result item."""
|
||||
repo = item.get("repository", {})
|
||||
owner_obj = repo.get("owner", {})
|
||||
full_name = repo.get("full_name", "")
|
||||
|
||||
# text_matches may be present if Accept header includes text-match
|
||||
text_fragments = []
|
||||
for tm in item.get("text_matches", []):
|
||||
fragment = tm.get("fragment", "")
|
||||
if fragment:
|
||||
text_fragments.append(fragment)
|
||||
|
||||
return {
|
||||
"repo_id": full_name,
|
||||
"file_path": item.get("path", ""),
|
||||
"file_url": item.get("html_url", ""),
|
||||
"file_name": item.get("name", ""),
|
||||
"matched_text": "\n---\n".join(text_fragments) if text_fragments else "",
|
||||
"context": {
|
||||
"sha": item.get("sha", ""),
|
||||
"score": item.get("score", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_repo_record(repo_id: str, repo_data: dict) -> dict:
|
||||
"""Build a minimal repo_db-compatible record from code search results."""
|
||||
owner = repo_id.split("/")[0] if "/" in repo_id else ""
|
||||
name = repo_id.split("/")[1] if "/" in repo_id else repo_id
|
||||
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://github.com/{repo_id}",
|
||||
"name": name,
|
||||
"owner": owner,
|
||||
"description": repo_data.get("description", ""),
|
||||
"stars": 0,
|
||||
"forks": 0,
|
||||
"language": "",
|
||||
"license": "",
|
||||
"topics": [],
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"pushed_at": "",
|
||||
"open_issues": 0,
|
||||
"default_branch": "main",
|
||||
"archived": False,
|
||||
"source": "code_search",
|
||||
"code_matches": repo_data.get("files", []),
|
||||
"languages_pct": {},
|
||||
"readme_excerpt": "",
|
||||
"paper_ids": [],
|
||||
"paper_titles": [],
|
||||
"relevance_score": 0.0,
|
||||
"quality_score": 0.0,
|
||||
"activity_score": 0.0,
|
||||
"composite_score": 0.0,
|
||||
"tags": [],
|
||||
"analyzed": False,
|
||||
"local_path": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search GitHub code for function/class implementations via gh API."
|
||||
)
|
||||
parser.add_argument("--query", required=True, help="Code search query string")
|
||||
parser.add_argument("--language", default=None, help="Filter by programming language")
|
||||
parser.add_argument("--filename", default=None, help="Filter by filename pattern")
|
||||
parser.add_argument("--max-results", type=int, default=DEFAULT_MAX_RESULTS, help="Max code results to fetch (default: 50)")
|
||||
parser.add_argument("--output", required=True, help="Output JSONL file for repo-level records")
|
||||
args = parser.parse_args()
|
||||
|
||||
check_gh_installed()
|
||||
|
||||
full_query = build_code_query(args.query, args.language, args.filename)
|
||||
print(f"[info] code search query: {full_query}", file=sys.stderr)
|
||||
print(f"[info] max results: {args.max_results}", file=sys.stderr)
|
||||
|
||||
items = search_code(full_query, args.max_results)
|
||||
|
||||
# Process matches and group by repo
|
||||
code_matches = []
|
||||
repos: dict[str, dict] = {} # repo_id -> {description, file_count, files}
|
||||
|
||||
for item in items:
|
||||
match = extract_code_match(item)
|
||||
code_matches.append(match)
|
||||
|
||||
rid = match["repo_id"]
|
||||
if rid not in repos:
|
||||
repo_obj = item.get("repository", {})
|
||||
repos[rid] = {
|
||||
"description": repo_obj.get("description") or "",
|
||||
"file_count": 0,
|
||||
"files": [],
|
||||
}
|
||||
repos[rid]["file_count"] += 1
|
||||
repos[rid]["files"].append(match["file_path"])
|
||||
|
||||
# Print code matches to stderr, grouped by repo
|
||||
print(f"\n{'=' * 60}", file=sys.stderr)
|
||||
print(f"Code matches: {len(code_matches)} files in {len(repos)} repos", file=sys.stderr)
|
||||
print(f"{'=' * 60}", file=sys.stderr)
|
||||
|
||||
for rid in sorted(repos.keys()):
|
||||
info = repos[rid]
|
||||
print(f"\n {rid} ({info['file_count']} files)", file=sys.stderr)
|
||||
if info["description"]:
|
||||
print(f" {info['description'][:100]}", file=sys.stderr)
|
||||
for fpath in info["files"][:5]:
|
||||
print(f" - {fpath}", file=sys.stderr)
|
||||
if len(info["files"]) > 5:
|
||||
print(f" ... and {len(info['files']) - 5} more", file=sys.stderr)
|
||||
|
||||
# Write repo-level JSONL to --output (for feeding into repo_db merge)
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for rid, rdata in sorted(repos.items()):
|
||||
record = build_repo_record(rid, rdata)
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
print(f"\n[info] wrote {len(repos)} repo records to {output_path}", file=sys.stderr)
|
||||
|
||||
# Also write code-level matches alongside (with .code suffix)
|
||||
code_output = output_path.with_suffix(".code.jsonl")
|
||||
with open(code_output, "w", encoding="utf-8") as f:
|
||||
for match in code_matches:
|
||||
f.write(json.dumps(match, ensure_ascii=False) + "\n")
|
||||
|
||||
print(f"[info] wrote {len(code_matches)} code matches to {code_output}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search Papers With Code for paper-to-repo mappings.
|
||||
|
||||
Uses the Papers With Code public API (https://paperswithcode.com/api/v1/)
|
||||
to find GitHub repositories linked to research papers. Supports lookup by
|
||||
single arXiv ID, multiple IDs, a file of IDs, or keyword search.
|
||||
|
||||
Self-contained: stdlib only (urllib for HTTP).
|
||||
|
||||
Usage:
|
||||
python search_paperswithcode.py --arxiv-id 2301.12345 --output repos.jsonl
|
||||
python search_paperswithcode.py --query "multi-agent" --output repos.jsonl
|
||||
python search_paperswithcode.py --arxiv-ids-file papers.txt --output repos.jsonl
|
||||
python search_paperswithcode.py --arxiv-ids 2301.12345 2305.67890 --output repos.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PWC_API = "https://paperswithcode.com/api/v1"
|
||||
USER_AGENT = "github-research/1.0"
|
||||
REQUEST_DELAY = 1.0 # seconds between requests
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pwc_request(url: str) -> dict | list | None:
|
||||
"""Make a GET request to the Papers With Code API with retry logic.
|
||||
|
||||
Returns parsed JSON or None on failure.
|
||||
"""
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
if e.code == 429:
|
||||
wait = 2 ** (attempt + 1)
|
||||
print(f"[warn] rate limited, waiting {wait}s ...", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
print(f"[warn] HTTP {e.code} for {url}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return None
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"[warn] request failed: {e}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def paginate_results(url: str) -> list[dict]:
|
||||
"""Follow ``next`` links and collect all ``results`` items."""
|
||||
items: list[dict] = []
|
||||
current_url: str | None = url
|
||||
while current_url:
|
||||
data = pwc_request(current_url)
|
||||
if not data or not isinstance(data, dict):
|
||||
break
|
||||
items.extend(data.get("results", []))
|
||||
current_url = data.get("next")
|
||||
if current_url:
|
||||
time.sleep(REQUEST_DELAY)
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub URL parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_repo_url(url: str) -> tuple[str, str]:
|
||||
"""Parse a GitHub URL into (owner, name). Returns ("", "") if not GitHub."""
|
||||
if not url or "github.com" not in url:
|
||||
return "", ""
|
||||
url = url.rstrip("/")
|
||||
for prefix in ("https://github.com/", "http://github.com/"):
|
||||
if url.startswith(prefix):
|
||||
path = url[len(prefix):]
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 2 and parts[0] and parts[1]:
|
||||
name = parts[1].removesuffix(".git")
|
||||
return parts[0], name
|
||||
return "", ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo record mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def map_to_repo_record(
|
||||
repo: dict,
|
||||
paper_title: str = "",
|
||||
arxiv_id: str = "",
|
||||
) -> dict | None:
|
||||
"""Map a PWC repository entry to the repo_db schema.
|
||||
|
||||
Returns None if the URL is not a valid GitHub repo.
|
||||
"""
|
||||
url = repo.get("url", "")
|
||||
owner, name = parse_repo_url(url)
|
||||
if not owner or not name:
|
||||
return None
|
||||
|
||||
repo_id = f"{owner}/{name}"
|
||||
paper_ids = [arxiv_id] if arxiv_id else []
|
||||
paper_titles = [paper_title] if paper_title else []
|
||||
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://github.com/{repo_id}",
|
||||
"name": name,
|
||||
"owner": owner,
|
||||
"description": repo.get("description") or "",
|
||||
"stars": repo.get("stars", 0) or 0,
|
||||
"forks": 0,
|
||||
"language": repo.get("framework") or "",
|
||||
"license": "",
|
||||
"topics": [],
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"pushed_at": "",
|
||||
"open_issues": 0,
|
||||
"default_branch": "main",
|
||||
"archived": False,
|
||||
"source": "paperswithcode",
|
||||
"paper_ids": paper_ids,
|
||||
"paper_titles": paper_titles,
|
||||
"is_official": repo.get("is_official", False),
|
||||
# Default fields for repo_db compatibility
|
||||
"languages_pct": {},
|
||||
"readme_excerpt": "",
|
||||
"relevance_score": 0.0,
|
||||
"quality_score": 0.0,
|
||||
"activity_score": 0.0,
|
||||
"composite_score": 0.0,
|
||||
"tags": [],
|
||||
"analyzed": False,
|
||||
"local_path": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core lookup functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lookup_paper_by_arxiv(arxiv_id: str) -> dict | None:
|
||||
"""Look up a paper on PWC by arXiv ID. Returns first matching paper or None."""
|
||||
clean_id = arxiv_id.strip()
|
||||
# Remove version suffix if present (e.g. 2301.12345v2 -> 2301.12345)
|
||||
if "v" in clean_id and clean_id[-1].isdigit():
|
||||
base, _, ver = clean_id.rpartition("v")
|
||||
if ver.isdigit():
|
||||
clean_id = base
|
||||
|
||||
url = f"{PWC_API}/papers/?arxiv_id={urllib.parse.quote(clean_id, safe='')}"
|
||||
data = pwc_request(url)
|
||||
if not data or not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
results = data.get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def fetch_paper_repos(paper_id: str) -> list[dict]:
|
||||
"""Fetch all repositories linked to a paper by its PWC paper ID."""
|
||||
url = f"{PWC_API}/papers/{urllib.parse.quote(paper_id, safe='')}/repositories/"
|
||||
return paginate_results(url)
|
||||
|
||||
|
||||
def search_papers_by_query(query: str, max_papers: int = 50) -> list[dict]:
|
||||
"""Search PWC papers by keyword query."""
|
||||
papers: list[dict] = []
|
||||
page = 1
|
||||
|
||||
while len(papers) < max_papers:
|
||||
params = urllib.parse.urlencode({"q": query, "page": page})
|
||||
url = f"{PWC_API}/papers/?{params}"
|
||||
data = pwc_request(url)
|
||||
if not data or not isinstance(data, dict):
|
||||
break
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
break
|
||||
|
||||
papers.extend(results)
|
||||
|
||||
if data.get("next"):
|
||||
page += 1
|
||||
time.sleep(REQUEST_DELAY)
|
||||
else:
|
||||
break
|
||||
|
||||
return papers[:max_papers]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processing functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_arxiv_id(arxiv_id: str) -> tuple[list[dict], str]:
|
||||
"""Process a single arXiv ID: look up paper, fetch repos.
|
||||
|
||||
Returns (repo_records, paper_title).
|
||||
"""
|
||||
clean_id = arxiv_id.strip()
|
||||
if not clean_id:
|
||||
return [], ""
|
||||
|
||||
print(f" arXiv:{clean_id} ...", file=sys.stderr)
|
||||
paper = lookup_paper_by_arxiv(clean_id)
|
||||
if not paper:
|
||||
print(f" not found on PWC", file=sys.stderr)
|
||||
return [], ""
|
||||
|
||||
paper_id = paper.get("id", "")
|
||||
paper_title = paper.get("title", "")
|
||||
repo_count = paper.get("repository_count", 0)
|
||||
|
||||
print(f" {paper_title[:80]}", file=sys.stderr)
|
||||
print(f" repos: {repo_count}", file=sys.stderr)
|
||||
|
||||
if not paper_id or repo_count == 0:
|
||||
return [], paper_title
|
||||
|
||||
time.sleep(REQUEST_DELAY)
|
||||
raw_repos = fetch_paper_repos(paper_id)
|
||||
|
||||
records = []
|
||||
for repo in raw_repos:
|
||||
record = map_to_repo_record(repo, paper_title=paper_title, arxiv_id=clean_id)
|
||||
if record:
|
||||
records.append(record)
|
||||
|
||||
print(f" mapped {len(records)} GitHub repos", file=sys.stderr)
|
||||
return records, paper_title
|
||||
|
||||
|
||||
def process_query(query: str) -> list[dict]:
|
||||
"""Search PWC by keyword and fetch repos for matching papers."""
|
||||
print(f"[info] searching PWC for: {query}", file=sys.stderr)
|
||||
papers = search_papers_by_query(query)
|
||||
print(f"[info] found {len(papers)} papers", file=sys.stderr)
|
||||
|
||||
all_records: list[dict] = []
|
||||
papers_with_repos = 0
|
||||
|
||||
for i, paper in enumerate(papers):
|
||||
paper_id = paper.get("id", "")
|
||||
paper_title = paper.get("title", "")
|
||||
arxiv_id = paper.get("arxiv_id", "") or ""
|
||||
repo_count = paper.get("repository_count", 0)
|
||||
|
||||
if not paper_id or repo_count == 0:
|
||||
continue
|
||||
|
||||
print(
|
||||
f" [{i + 1}/{len(papers)}] {paper_title[:60]} ({repo_count} repos)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
time.sleep(REQUEST_DELAY)
|
||||
raw_repos = fetch_paper_repos(paper_id)
|
||||
|
||||
for repo in raw_repos:
|
||||
record = map_to_repo_record(
|
||||
repo, paper_title=paper_title, arxiv_id=arxiv_id
|
||||
)
|
||||
if record:
|
||||
all_records.append(record)
|
||||
|
||||
papers_with_repos += 1
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
print(f"[info] {papers_with_repos} papers had repos", file=sys.stderr)
|
||||
return all_records
|
||||
|
||||
|
||||
def deduplicate_repos(records: list[dict]) -> list[dict]:
|
||||
"""Deduplicate repos by repo_id, merging paper_ids and paper_titles."""
|
||||
seen: dict[str, dict] = {}
|
||||
for record in records:
|
||||
rid = record["repo_id"]
|
||||
if rid in seen:
|
||||
existing = seen[rid]
|
||||
# Merge paper references
|
||||
for pid in record.get("paper_ids", []):
|
||||
if pid and pid not in existing["paper_ids"]:
|
||||
existing["paper_ids"].append(pid)
|
||||
for pt in record.get("paper_titles", []):
|
||||
if pt and pt not in existing["paper_titles"]:
|
||||
existing["paper_titles"].append(pt)
|
||||
# Keep higher star count
|
||||
if record.get("stars", 0) > existing.get("stars", 0):
|
||||
existing["stars"] = record["stars"]
|
||||
# Prefer official repos
|
||||
if record.get("is_official", False):
|
||||
existing["is_official"] = True
|
||||
else:
|
||||
seen[rid] = record
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search Papers With Code for paper-to-repo mappings."
|
||||
)
|
||||
parser.add_argument("--arxiv-id", default=None,
|
||||
help="Single arXiv ID to look up")
|
||||
parser.add_argument("--arxiv-ids", nargs="+", default=None,
|
||||
help="Multiple arXiv IDs")
|
||||
parser.add_argument("--arxiv-ids-file", default=None,
|
||||
help="File with one arXiv ID per line")
|
||||
parser.add_argument("--query", default=None,
|
||||
help="Search papers by keyword")
|
||||
parser.add_argument("--output", required=True,
|
||||
help="Output JSONL file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate: at least one input source required
|
||||
if not any([args.arxiv_id, args.arxiv_ids, args.arxiv_ids_file, args.query]):
|
||||
parser.error(
|
||||
"At least one of --arxiv-id, --arxiv-ids, --arxiv-ids-file, "
|
||||
"or --query is required"
|
||||
)
|
||||
|
||||
# Collect all arXiv IDs from all sources
|
||||
all_arxiv_ids: list[str] = []
|
||||
if args.arxiv_id:
|
||||
all_arxiv_ids.append(args.arxiv_id)
|
||||
if args.arxiv_ids:
|
||||
all_arxiv_ids.extend(args.arxiv_ids)
|
||||
if args.arxiv_ids_file:
|
||||
ids_file = Path(args.arxiv_ids_file)
|
||||
if not ids_file.exists():
|
||||
print(f"[error] arXiv IDs file not found: {ids_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
for line in ids_file.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
all_arxiv_ids.append(line)
|
||||
except OSError as e:
|
||||
print(f"[error] cannot read {ids_file}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
all_records: list[dict] = []
|
||||
papers_found = 0
|
||||
|
||||
# Process arXiv IDs
|
||||
if all_arxiv_ids:
|
||||
print(
|
||||
f"[info] looking up {len(all_arxiv_ids)} arXiv IDs on Papers With Code ...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for arxiv_id in all_arxiv_ids:
|
||||
records, title = process_arxiv_id(arxiv_id)
|
||||
if title:
|
||||
papers_found += 1
|
||||
all_records.extend(records)
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
# Process keyword query
|
||||
if args.query:
|
||||
query_records = process_query(args.query)
|
||||
all_records.extend(query_records)
|
||||
|
||||
# Deduplicate
|
||||
deduped = deduplicate_repos(all_records)
|
||||
|
||||
# Write output
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for record in deduped:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# Summary
|
||||
total_paper_links = sum(len(r.get("paper_ids", [])) for r in deduped)
|
||||
official = sum(1 for r in deduped if r.get("is_official", False))
|
||||
|
||||
print(f"\n[info] results summary:", file=sys.stderr)
|
||||
print(f" repos found: {len(deduped)}", file=sys.stderr)
|
||||
print(f" linked to papers: {total_paper_links}", file=sys.stderr)
|
||||
if official:
|
||||
print(f" official implementations: {official}", file=sys.stderr)
|
||||
print(f"Found {len(deduped)} repos linked to {papers_found} papers", file=sys.stderr)
|
||||
print(f"[info] written to {output_path}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user