first commit

This commit is contained in:
jsahn
2026-01-08 15:50:54 +09:00
commit 64b646666f
13 changed files with 1943 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
GitHub Issue + Commit Diff Crawler
- flask_issues.json 과 100% 동일한 구조로 JSON 생성
[
{
"id": <issue_id>,
"url": "<issue_api_url>",
"title": "<issue_title>",
"body": "<issue_body>",
"commits": [
{
"filename": "<file_path>",
"patch": "<unified_diff_patch>",
"date": "<ISO8601_commit_date>"
},
...
]
},
...
]
사용 예:
export GITHUB_TOKEN=xxxx
python crawl_issues_with_diffs.py pallets flask flask_issues.json
"""
import os
import sys
import time
import json
import requests
from typing import List, Dict, Any, Set, Optional
import dotenv
from tqdm import tqdm
dotenv.load_dotenv()
os.environ["GITHUB_TOKEN"] = os.getenv("GITHUB_TOKEN", "")
GITHUB_API = "https://api.github.com"
def make_session(token: str) -> requests.Session:
s = requests.Session()
s.headers.update(
{
"Authorization": f"token {token}",
"Accept": "application/vnd.github+json",
}
)
return s
def fetch_issues(
session: requests.Session,
owner: str,
repo: str,
state: str = "all",
per_page: int = 100,
) -> List[Dict[str, Any]]:
"""모든 이슈(이슈 + PR)를 페이징 하면서 가져옴."""
issues: List[Dict[str, Any]] = []
page = 1
pbar = tqdm(desc=f"Fetching issues from {owner}/{repo}", unit="page")
while True:
url = f"{GITHUB_API}/repos/{owner}/{repo}/issues"
params = {
"state": state,
"per_page": per_page,
"page": page,
}
try:
resp = session.get(url, params=params)
except requests.RequestException as e:
tqdm.write(f"[ERROR] fetch_issues page={page} exception: {e}")
break
if resp.status_code != 200:
tqdm.write(f"[WARN] fetch_issues page={page} status={resp.status_code}")
break
batch = resp.json()
if not batch:
break
issues.extend(batch)
pbar.update(1)
pbar.set_postfix({"total_issues": len(issues)})
page += 1
time.sleep(0.2)
pbar.close()
return issues
def fetch_issue_timeline(
session: requests.Session,
owner: str,
repo: str,
issue_number: int,
) -> List[Dict[str, Any]]:
"""
Issue timeline API 로 cross-referenced PR 등을 찾음.
"""
url = f"{GITHUB_API}/repos/{owner}/{repo}/issues/{issue_number}/timeline"
headers = {
"Accept": "application/vnd.github.mockingbird-preview+json"
}
try:
resp = session.get(url, headers=headers)
except requests.RequestException as e:
print(f"[WARN] fetch_issue_timeline #{issue_number} exception: {e}")
return []
if resp.status_code != 200:
print(f"[WARN] fetch_issue_timeline #{issue_number} status={resp.status_code}")
return []
events = resp.json()
time.sleep(0.2)
return events
def collect_pr_numbers_for_issue(
session: requests.Session,
owner: str,
repo: str,
issue: Dict[str, Any],
) -> Set[int]:
"""
한 issue 에 대해 연관된 PR 번호들을 수집:
1) 이슈 자체가 PR인 경우
2) timeline cross-referenced 로 연결된 PR들
"""
pr_numbers: Set[int] = set()
issue_number = issue["number"]
# 1) 이슈가 곧 PR인 경우
if "pull_request" in issue:
pr_numbers.add(issue_number)
# 2) 타임라인에서 cross-referenced PR 찾기
events = fetch_issue_timeline(session, owner, repo, issue_number)
for ev in events:
if ev.get("event") == "cross-referenced":
src_issue = ev.get("source", {}).get("issue", {})
if src_issue.get("pull_request"):
pr_num = src_issue.get("number")
if isinstance(pr_num, int):
pr_numbers.add(pr_num)
return pr_numbers
def fetch_pr_commits(
session: requests.Session,
owner: str,
repo: str,
pr_number: int,
) -> List[Dict[str, Any]]:
"""PR에 포함된 commit 리스트 가져오기."""
url = f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}/commits"
try:
resp = session.get(url)
except requests.RequestException as e:
print(f"[WARN] fetch_pr_commits PR#{pr_number} exception: {e}")
return []
if resp.status_code != 200:
print(f"[WARN] fetch_pr_commits PR#{pr_number} status={resp.status_code}")
return []
commits = resp.json()
time.sleep(0.2)
return commits
def fetch_commit_files(
session: requests.Session,
owner: str,
repo: str,
sha: str,
) -> Optional[Dict[str, Any]]:
"""commit 상세(파일 + patch 포함) 가져오기."""
url = f"{GITHUB_API}/repos/{owner}/{repo}/commits/{sha}"
try:
resp = session.get(url)
except requests.RequestException as e:
print(f"[WARN] fetch_commit_files sha={sha} exception: {e}")
return None
if resp.status_code != 200:
print(f"[WARN] fetch_commit_files sha={sha} status={resp.status_code}")
return None
commit_detail = resp.json()
time.sleep(0.2)
return commit_detail
def atomic_save_json(path: str, data: Any) -> None:
"""임시 파일에 저장 후 rename 으로 교체 (중간에 죽어도 파일 깨지는 것 방지)."""
tmp_path = path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, path)
def crawl_issues_with_diffs(
owner: str,
repo: str,
token: str,
output_path: str,
existing_results: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""
- 기존에 저장된 결과가 있으면 이어서 크롤링.
- 각 issue 처리 후마다 output_path 에 바로 저장.
"""
session = make_session(token)
all_issues = fetch_issues(session, owner, repo)
# 이미 저장된 결과가 있으면 이어서
if existing_results is None:
results: List[Dict[str, Any]] = []
else:
results = list(existing_results)
processed_ids: Set[int] = {item["id"] for item in results}
# Filter issues that need processing
issues_to_process = [i for i in all_issues if i["id"] not in processed_ids]
pbar = tqdm(
issues_to_process,
desc=f"Crawling {owner}/{repo}",
unit="issue",
initial=len(processed_ids),
total=len(all_issues),
)
for issue in pbar:
issue_id = issue["id"]
issue_url = issue["url"]
title = issue.get("title", "")
body = issue.get("body", "")
pbar.set_postfix({"issue": issue["number"]})
pr_numbers = collect_pr_numbers_for_issue(session, owner, repo, issue)
commits_entries: List[Dict[str, Any]] = []
for pr_num in pr_numbers:
pr_commits = fetch_pr_commits(session, owner, repo, pr_num)
for c in pr_commits:
sha = c["sha"]
commit_detail = fetch_commit_files(session, owner, repo, sha)
if not commit_detail:
continue
commit_date = (
commit_detail
.get("commit", {})
.get("author", {})
.get("date")
)
for f in commit_detail.get("files", []):
patch = f.get("patch")
if not patch:
continue
filename = f.get("filename")
commits_entries.append(
{
"filename": filename,
"patch": patch,
"date": commit_date,
}
)
issue_record = {
"id": issue_id,
"url": issue_url,
"title": title,
"body": body,
"commits": commits_entries,
}
results.append(issue_record)
processed_ids.add(issue_id)
atomic_save_json(output_path, results)
pbar.close()
return results
def main():
if len(sys.argv) != 4:
print(
"Usage: python crawl_issues_with_diffs.py <owner> <repo> <output_json>\n"
"Example:\n"
' python crawl_issues_with_diffs.py pallets flask flask_issues.json'
)
sys.exit(1)
owner = sys.argv[1]
repo = sys.argv[2]
output_path = sys.argv[3]
token = os.environ.get("GITHUB_TOKEN")
if not token:
print("ERROR: GITHUB_TOKEN 환경변수를 설정해 주세요.")
sys.exit(1)
# 🔹 기존 결과가 있으면 먼저 로드해서 이어서 크롤링
existing_results: Optional[List[Dict[str, Any]]] = None
if os.path.exists(output_path):
try:
with open(output_path, "r", encoding="utf-8") as f:
existing_results = json.load(f)
print(f"[INFO] loaded existing results from {output_path}")
except Exception as e:
print(f"[WARN] failed to load existing {output_path}: {e}")
existing_results = None
results = crawl_issues_with_diffs(owner, repo, token, output_path, existing_results)
# 마지막으로 한 번 더 저장 (안전)
atomic_save_json(output_path, results)
print(f"[DONE] saved {len(results)} issues to {output_path}")
if __name__ == "__main__":
main()