diff --git a/src/lysis/cli.py b/src/lysis/cli.py new file mode 100644 index 0000000..cade0d2 --- /dev/null +++ b/src/lysis/cli.py @@ -0,0 +1,39 @@ +"""Rank a repo's open issues: python src\\lysis\\cli.py owner/repo [top]""" + +import sys + +import pandas as pd +from dotenv import load_dotenv + +from lysis.features.rows import issue_row +from lysis.ingestion.github_client import GitHubClient +from lysis.models.evaluate import load_dataset +from lysis.models.gbm import make_gbm_ranker + + +def rank_repo(gh, full_name, ranker, top=10): + owner, name = full_name.split("/") + repo = gh.get(f"/repos/{full_name}") + issues = [i for i in gh.list_issues(owner, name, state="open") if not i.get("assignees")] + if not issues: + return pd.DataFrame(columns=["score", "number", "title", "url"]) + df = pd.DataFrame([issue_row(i, repo) for i in issues]) + df["score"] = list(ranker(df)) + df = df.sort_values("score", ascending=False).head(top) + df["number"] = df["issue_number"] + df["title"] = df["title"].str[:60] + df["score"] = df["score"].round(3) + return df[["score", "number", "title", "url"]] + + +def main(): + load_dotenv() + if len(sys.argv) < 2: + sys.exit("usage: python src\\lysis\\cli.py owner/repo [top]") + top = int(sys.argv[2]) if len(sys.argv) > 2 else 10 + ranker = make_gbm_ranker(load_dataset()) + print(rank_repo(GitHubClient(), sys.argv[1], ranker, top).to_string(index=False)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/lysis/features/rows.py b/src/lysis/features/rows.py new file mode 100644 index 0000000..10d6cc2 --- /dev/null +++ b/src/lysis/features/rows.py @@ -0,0 +1,23 @@ +"""Build one model-input row from a GitHub issue and its repo.""" + + +def issue_row(issue, repo): + body = issue.get("body") or "" + return { + "repo": repo["full_name"], + "issue_number": issue["number"], + "title": issue.get("title") or "", + "url": issue.get("html_url", ""), + "labels": [x["name"].lower() for x in issue["labels"]], + "comments": issue["comments"], + "body_len": len(body), + "title_len": len(issue.get("title") or ""), + "has_code": int("```" in body), + "author_assoc": issue.get("author_association") or "NONE", + "stars": repo["stargazers_count"], + "forks": repo["forks_count"], + "repo_size": repo["size"], + "has_license": int(bool(repo.get("license"))), + "repo_created_at": repo["created_at"], + "created_at": issue["created_at"], + } \ No newline at end of file diff --git a/src/lysis/models/evaluate.py b/src/lysis/models/evaluate.py index 5cd619b..b24fd05 100644 --- a/src/lysis/models/evaluate.py +++ b/src/lysis/models/evaluate.py @@ -8,6 +8,7 @@ from sklearn.metrics import average_precision_score, roc_auc_score from sklearn.model_selection import GroupKFold +from lysis.features.rows import issue_row from lysis.models.baseline import labels_only_ranker from lysis.models.gbm import make_gbm_ranker @@ -22,26 +23,9 @@ def load_dataset(raw_dir="data/raw", labels_path="data/labels.csv"): for path in Path(raw_dir).glob("*.json"): d = json.loads(path.read_text()) r = d["repo"] - name = r["full_name"] cutoff = min((p["created_at"] for p in d["merged_prs"]), default=None) rows += [ - { - "repo": name, - "issue_number": i["number"], - "labels": [x["name"].lower() for x in i["labels"]], - "comments": i["comments"], - "body_len": len(i.get("body") or ""), - "title_len": len(i.get("title") or ""), - "text": f"{i.get('title') or ''} {i.get('body') or ''}"[:2000], - "has_code": int("```" in (i.get("body") or "")), - "author_assoc": i.get("author_association") or "NONE", - "stars": r["stargazers_count"], - "forks": r["forks_count"], - "repo_size": r["size"], - "has_license": int(bool(r.get("license"))), - "repo_created_at": r["created_at"], - "created_at": i["created_at"], - } + issue_row(i, r) for i in d["closed_issues"] if cutoff and i["created_at"] >= cutoff ] @@ -49,7 +33,6 @@ def load_dataset(raw_dir="data/raw", labels_path="data/labels.csv"): df["first_timer"] = df["first_timer"].fillna(0).astype(int) return df - def split_by_repo(df, test_frac=0.3, seed=42): repos = pd.Series(sorted(df["repo"].unique())).sample(frac=1, random_state=seed) test_repos = set(repos[: max(1, int(len(repos) * test_frac))]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..98712d9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,33 @@ +from lysis.cli import rank_repo + + +class FakeGH: + def get(self, path, params=None): + return { + "full_name": "a/b", + "stargazers_count": 10, + "forks_count": 1, + "size": 100, + "license": None, + "created_at": "2020-01-01T00:00:00Z", + } + + def list_issues(self, owner, repo, state="open"): + base = { + "labels": [], + "comments": 0, + "body": "x", + "created_at": "2026-01-01T00:00:00Z", + "html_url": "u", + "assignees": [], + } + return [ + {**base, "number": 1, "title": "one"}, + {**base, "number": 2, "title": "two", "labels": [{"name": "good first issue"}]}, + {**base, "number": 3, "title": "taken", "assignees": [{"login": "x"}]}, + ] + + +def test_rank_repo(): + out = rank_repo(FakeGH(), "a/b", lambda df: df["labels"].apply(len), top=5) + assert list(out["number"]) == [2, 1] \ No newline at end of file