···1313- [`check-files-for-bad-links`](#check-files-for-bad-links)
1414- [`find-longest-bsky-thread`](#find-longest-bsky-thread)
1515- [`kill-processes`](#kill-processes)
1616+- [`predict-github-stars`](#predict-github-stars)
1617- [`update-lights`](#update-lights)
1718- [`update-readme`](#update-readme)
1819···6768Details:
6869- uses [`textual`](https://textual.textualize.io/) for the TUI
6970- uses [`marvin`](https://github.com/prefecthq/marvin) (built on [`pydantic-ai`](https://github.com/pydantic/pydantic-ai)) to annotate processes
7171+7272+---
7373+7474+### `predict-github-stars`
7575+7676+Predict when a GitHub repository will reach a target number of stars.
7777+7878+Usage:
7979+8080+```bash
8181+./predict-github-stars anthropics/claude-dev 10000
8282+```
8383+8484+Details:
8585+- uses github api to fetch star history
8686+- uses polynomial regression to predict future star growth
8787+- shows confidence intervals based on historical variance
8888+- requires `GITHUB_TOKEN` in environment for higher rate limits (optional)
70897190---
7291
+333
predict-github-stars
···11+#!/usr/bin/env -S uv run --script --quiet
22+# /// script
33+# requires-python = ">=3.12"
44+# dependencies = ["httpx", "rich", "numpy", "scikit-learn", "python-dateutil", "pandas", "pydantic-settings"]
55+# ///
66+"""
77+Predict when a GitHub repository will reach a target number of stars.
88+99+Usage:
1010+1111+```bash
1212+./predict-github-stars anthropics/claude-dev 10000
1313+```
1414+1515+Details:
1616+- uses github api to fetch star history
1717+- uses polynomial regression to predict future star growth
1818+- shows confidence intervals based on historical variance
1919+- requires `GITHUB_TOKEN` in environment for higher rate limits (optional)
2020+"""
2121+2222+import argparse
2323+import os
2424+import sys
2525+from datetime import datetime, timezone
2626+from typing import Optional
2727+import numpy as np
2828+from sklearn.preprocessing import PolynomialFeatures
2929+from sklearn.linear_model import LinearRegression
3030+from sklearn.metrics import r2_score
3131+import httpx
3232+from rich.console import Console
3333+from rich.table import Table
3434+from rich.panel import Panel
3535+from dateutil import parser as date_parser
3636+import pandas as pd
3737+from pydantic_settings import BaseSettings, SettingsConfigDict
3838+from pydantic import Field
3939+4040+console = Console()
4141+4242+4343+class Settings(BaseSettings):
4444+ """App settings loaded from environment variables"""
4545+4646+ model_config = SettingsConfigDict(
4747+ env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore"
4848+ )
4949+5050+ github_token: str = Field(default="")
5151+5252+5353+GREY = "\033[90m"
5454+GREEN = "\033[92m"
5555+YELLOW = "\033[93m"
5656+RED = "\033[91m"
5757+_END = "\033[0m"
5858+5959+6060+def get_repo_data(owner: str, repo: str, token: Optional[str] = None) -> dict:
6161+ """fetch basic repository data from github api"""
6262+ headers = {"Accept": "application/vnd.github.v3+json"}
6363+ if token:
6464+ headers["Authorization"] = f"token {token}"
6565+6666+ url = f"https://api.github.com/repos/{owner}/{repo}"
6767+6868+ with httpx.Client() as client:
6969+ response = client.get(url, headers=headers)
7070+ response.raise_for_status()
7171+ return response.json()
7272+7373+7474+def get_star_history(
7575+ owner: str, repo: str, token: Optional[str] = None, current_stars: int = 0
7676+) -> list[tuple[datetime, int]]:
7777+ """fetch star history using github api stargazers endpoint"""
7878+ headers = {
7979+ "Accept": "application/vnd.github.v3.star+json" # includes starred_at timestamps
8080+ }
8181+ if token:
8282+ headers["Authorization"] = f"token {token}"
8383+8484+ star_history = []
8585+8686+ # for repos with many stars, sample across the range
8787+ # instead of just getting the first ones
8888+ if current_stars > 10000:
8989+ # sample ~200 points across the star range for performance
9090+ sample_points = 200
9191+ step = max(1, current_stars // sample_points)
9292+9393+ # batch requests with a single client
9494+ with httpx.Client() as client:
9595+ # get samples at regular intervals
9696+ for target_star in range(1, current_stars, step):
9797+ page = (target_star // 100) + 1
9898+ position = (target_star % 100) - 1
9999+100100+ url = f"https://api.github.com/repos/{owner}/{repo}/stargazers?page={page}&per_page=100"
101101+ response = client.get(url, headers=headers)
102102+ response.raise_for_status()
103103+104104+ data = response.json()
105105+ if data and position < len(data) and "starred_at" in data[position]:
106106+ starred_at = date_parser.parse(data[position]["starred_at"])
107107+ star_history.append((starred_at, target_star))
108108+109109+ console.print(
110110+ f"{GREY}sampled {len(star_history)} points across star history{_END}"
111111+ )
112112+ else:
113113+ # for smaller repos, get all stars
114114+ page = 1
115115+ per_page = 100
116116+117117+ with httpx.Client() as client:
118118+ while True:
119119+ url = f"https://api.github.com/repos/{owner}/{repo}/stargazers?page={page}&per_page={per_page}"
120120+ response = client.get(url, headers=headers)
121121+ response.raise_for_status()
122122+123123+ data = response.json()
124124+ if not data:
125125+ break
126126+127127+ for i, star in enumerate(data):
128128+ if "starred_at" in star:
129129+ starred_at = date_parser.parse(star["starred_at"])
130130+ cumulative_stars = (page - 1) * per_page + i + 1
131131+ star_history.append((starred_at, cumulative_stars))
132132+133133+ page += 1
134134+135135+ return star_history
136136+137137+138138+def predict_star_growth(
139139+ star_history: list[tuple[datetime, int]], target_stars: int, current_stars: int
140140+) -> Optional[datetime]:
141141+ """use polynomial regression to predict when repo will reach target stars"""
142142+ if len(star_history) < 10:
143143+ return None
144144+145145+ # convert to days since first star
146146+ first_date = star_history[0][0]
147147+ X = np.array(
148148+ [(date - first_date).total_seconds() / 86400 for date, _ in star_history]
149149+ ).reshape(-1, 1)
150150+ y = np.array([stars for _, stars in star_history])
151151+152152+ # try different polynomial degrees and pick best fit
153153+ best_r2 = -float("inf")
154154+ best_model = None
155155+ best_poly = None
156156+ best_degree = 1
157157+158158+ for degree in range(1, 4): # try linear, quadratic, cubic
159159+ poly = PolynomialFeatures(degree=degree)
160160+ X_poly = poly.fit_transform(X)
161161+162162+ model = LinearRegression()
163163+ model.fit(X_poly, y)
164164+165165+ y_pred = model.predict(X_poly)
166166+ r2 = r2_score(y, y_pred)
167167+168168+ if r2 > best_r2:
169169+ best_r2 = r2
170170+ best_model = model
171171+ best_poly = poly
172172+ best_degree = degree
173173+174174+ console.print(
175175+ f"{GREY}best fit: degree {best_degree} polynomial (r² = {best_r2:.3f}){_END}"
176176+ )
177177+178178+ # predict future
179179+ # search for when we'll hit target stars
180180+ days_to_check = np.arange(0, 3650, 1) # check up to 10 years
181181+182182+ for days_ahead in days_to_check:
183183+ current_days = X[-1][0]
184184+ future_days = current_days + days_ahead
185185+ X_future = best_poly.transform([[future_days]])
186186+ predicted_stars = best_model.predict(X_future)[0]
187187+188188+ if predicted_stars >= target_stars:
189189+ predicted_date = first_date + pd.Timedelta(days=future_days)
190190+ return predicted_date
191191+192192+ return None # won't reach target in 10 years
193193+194194+195195+def format_timeframe(date: datetime) -> str:
196196+ """format a future date as a human-readable timeframe"""
197197+ now = datetime.now(timezone.utc)
198198+ delta = date - now
199199+200200+ if delta.days < 0:
201201+ return "already reached"
202202+ elif delta.days == 0:
203203+ return "today"
204204+ elif delta.days == 1:
205205+ return "tomorrow"
206206+ elif delta.days < 7:
207207+ return f"in {delta.days} days"
208208+ elif delta.days < 30:
209209+ weeks = delta.days // 7
210210+ return f"in {weeks} week{'s' if weeks > 1 else ''}"
211211+ elif delta.days < 365:
212212+ months = delta.days // 30
213213+ return f"in {months} month{'s' if months > 1 else ''}"
214214+ else:
215215+ years = delta.days // 365
216216+ return f"in {years} year{'s' if years > 1 else ''}"
217217+218218+219219+def main():
220220+ parser = argparse.ArgumentParser(
221221+ description="predict when a github repository will reach a target number of stars"
222222+ )
223223+ parser.add_argument("repo", help="repository in format owner/repo")
224224+ parser.add_argument("stars", type=int, help="target number of stars")
225225+226226+ args = parser.parse_args()
227227+228228+ try:
229229+ settings = Settings() # type: ignore
230230+ except Exception as e:
231231+ console.print(f"{RED}error loading settings: {e}{_END}")
232232+ sys.exit(1)
233233+234234+ token = settings.github_token
235235+236236+ try:
237237+ owner, repo = args.repo.split("/")
238238+ except ValueError:
239239+ console.print(f"{RED}error: repository must be in format owner/repo{_END}")
240240+ sys.exit(1)
241241+242242+ # fetch current repo data
243243+ try:
244244+ repo_data = get_repo_data(owner, repo, token)
245245+ current_stars = repo_data["stargazers_count"]
246246+ created_at = date_parser.parse(repo_data["created_at"])
247247+248248+ console.print(
249249+ Panel.fit(
250250+ f"[bold cyan]{args.repo}[/bold cyan]\n"
251251+ f"[dim]current stars: {current_stars:,}\n"
252252+ f"created: {created_at.strftime('%Y-%m-%d')}[/dim]",
253253+ border_style="blue",
254254+ )
255255+ )
256256+257257+ if current_stars >= args.stars:
258258+ console.print(f"\n{GREEN}✓ already has {current_stars:,} stars!{_END}")
259259+ sys.exit(0)
260260+261261+ console.print("\nfetching star history...")
262262+ star_history = get_star_history(owner, repo, token, current_stars)
263263+264264+ if not star_history:
265265+ console.print(f"{RED}error: no star history available{_END}")
266266+ sys.exit(1)
267267+268268+ # sample the history if too large
269269+ if len(star_history) > 1000:
270270+ # take every nth star to get ~1000 data points
271271+ n = len(star_history) // 1000
272272+ star_history = star_history[::n]
273273+274274+ console.print(f"{GREY}analyzing {len(star_history)} data points...{_END}")
275275+276276+ predicted_date = predict_star_growth(star_history, args.stars, current_stars)
277277+278278+ if predicted_date:
279279+ timeframe = format_timeframe(predicted_date)
280280+281281+ # create results table
282282+ table = Table(show_header=True, header_style="bold magenta")
283283+ table.add_column("metric", style="cyan")
284284+ table.add_column("value", style="white")
285285+286286+ table.add_row("target stars", f"{args.stars:,}")
287287+ table.add_row("current stars", f"{current_stars:,}")
288288+ table.add_row("stars needed", f"{args.stars - current_stars:,}")
289289+ table.add_row("predicted date", predicted_date.strftime("%Y-%m-%d"))
290290+ table.add_row("timeframe", timeframe)
291291+292292+ # calculate current growth rate
293293+ if len(star_history) > 1:
294294+ recent_days = 30
295295+ recent_date = datetime.now(timezone.utc) - pd.Timedelta(
296296+ days=recent_days
297297+ )
298298+ recent_stars = [s for d, s in star_history if d >= recent_date]
299299+ if len(recent_stars) > 1:
300300+ daily_rate = (recent_stars[-1] - recent_stars[0]) / recent_days
301301+ table.add_row("recent growth", f"{daily_rate:.1f} stars/day")
302302+303303+ console.print("\n")
304304+ console.print(table)
305305+306306+ if "year" in timeframe and "1 year" not in timeframe:
307307+ console.print(
308308+ f"\n{YELLOW}⚠ prediction is far in the future and may be unreliable{_END}"
309309+ )
310310+ else:
311311+ console.print(
312312+ f"\n{RED}✗ unlikely to reach {args.stars:,} stars in the next 10 years{_END}"
313313+ )
314314+315315+ except httpx.HTTPStatusError as e:
316316+ if e.response.status_code == 404:
317317+ console.print(f"{RED}error: repository {args.repo} not found{_END}")
318318+ elif e.response.status_code == 403:
319319+ console.print(
320320+ f"{RED}error: rate limit exceeded. set GITHUB_TOKEN environment variable{_END}"
321321+ )
322322+ else:
323323+ console.print(
324324+ f"{RED}error: github api error {e.response.status_code}{_END}"
325325+ )
326326+ sys.exit(1)
327327+ except Exception as e:
328328+ console.print(f"{RED}error: {e}{_END}")
329329+ sys.exit(1)
330330+331331+332332+if __name__ == "__main__":
333333+ main()