DEV Community

Charlie Hu
Charlie Hu

Posted on

Weekend Build Log: A Repo Digest Bot on Free Credits — Scope Cut, Demo, What Got Skipped

Scope control decides whether a weekend project ships. The model choice and the hosting choice matter far less. This build log documents a repo digest bot that went from idea to a running demo in about two days, on free model tokens and a free server, with half the planned features cut on purpose.

Recent DEV discussions keep circling the same observation: AI-assisted development raises the rate at which code lands, while human review speed stays flat. The bottleneck moved from writing to understanding. A digest bot does not fix review. It reduces the reading load by summarizing what already merged, so a developer can triage a week of changes in minutes instead of hours.

The constraint set

The project had three hard constraints:

  • Two days of calendar time, mostly evenings.
  • Zero budget.
  • No infrastructure beyond a single process to manage.

The goal was narrow: for a given GitHub repo, produce a readable weekly summary of merged pull requests and serve it as a web page. Nothing more.

Scope cut #1: no database, no auth, no workers

The first design had a database, user accounts, and a background job queue. It died in about an hour. The cut version is deliberately boring:

  1. A cron job runs one Python script once a week.
  2. The script fetches merged PRs from the GitHub API.
  3. It sends a compact prompt to a free LLM endpoint.
  4. It writes a static HTML file.
  5. A tiny static server serves that file.

No database. No auth. No queue. The whole system is two files and one cron line. A static page regenerated on a schedule is enough for a personal or small-team tool.

cron (weekly)
  └─> python3 digest.py
        ├─ GET /repos/{repo}/pulls?state=closed   (GitHub API)
        ├─ POST summary prompt                    (free LLM endpoint)
        └─ write public/index.html

serve.py
  └─ serves public/ on $PORT
Enter fullscreen mode Exit fullscreen mode

The code

digest.py

digest.py has three responsibilities, kept in three functions so each can be swapped or tested alone.

# digest.py
import html
import json
import os
import urllib.request
from datetime import datetime, timedelta, timezone

REPO = os.environ.get("GITHUB_REPO", "octocat/Hello-World")
DAYS = int(os.environ.get("DIGEST_DAYS", "7"))
LLM_ENDPOINT = os.environ["LLM_ENDPOINT"]
LLM_API_KEY = os.environ["LLM_API_KEY"]
LLM_MODEL = os.environ.get("LLM_MODEL", "default")


def fetch_merged_prs(repo, days):
    url = f"https://api.github.com/repos/{repo}/pulls?state=closed&sort=updated&direction=desc&per_page=30"
    req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
    since = datetime.now(timezone.utc) - timedelta(days=days)
    with urllib.request.urlopen(req, timeout=15) as resp:
        pulls = json.load(resp)
    merged = []
    for pr in pulls:
        merged_at = pr.get("merged_at")
        if not merged_at:
            continue
        merged_dt = datetime.fromisoformat(merged_at.replace("Z", "+00:00"))
        if merged_dt >= since:
            merged.append({
                "number": pr["number"],
                "title": pr["title"],
                "author": pr["user"]["login"],
                "html_url": pr["html_url"],
                "body": (pr.get("body") or "")[:500],
            })
    return merged


def summarize(prs):
    lines = [
        f"- #{pr['number']} ({pr['author']}): {pr['title']}\n  {pr['body']}"
        for pr in prs
    ]
    prompt = (
        "Write a short weekly digest of the merged pull requests below. "
        "Group related changes, keep it under 150 words, and flag anything risky.\n\n"
        + "\n".join(lines)
    )
    payload = {
        "model": LLM_MODEL,
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.3,
    }
    req = urllib.request.Request(
        LLM_ENDPOINT,
        data=json.dumps(payload).encode(),
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {LLM_API_KEY}",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=60) as resp:
        data = json.load(resp)
    # Assumes a chat-completions-style response. Adjust if your endpoint differs.
    return data["choices"][0]["message"]["content"]


def render(prs, digest_text):
    items = "".join(
        f"<li><a href='{pr['html_url']}'>#{pr['number']}</a> {html.escape(pr['title'])}</li>"
        for pr in prs
    )
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Weekly digest — {html.escape(REPO)}</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }}
pre {{ white-space: pre-wrap; background: #f6f8fa; padding: 1rem; border-radius: 8px; }}
</style>
</head>
<body>
<h1>Weekly digest</h1>
<p><code>{html.escape(REPO)}</code> — last {DAYS} days</p>
<pre>{html.escape(digest_text)}</pre>
<h2>Merged PRs</h2>
<ul>{items}</ul>
</body>
</html>"""


def main():
    prs = fetch_merged_prs(REPO, DAYS)
    if not prs:
        print("No merged PRs in the window. Nothing to do.")
        return
    digest_text = summarize(prs)
    os.makedirs("public", exist_ok=True)
    with open("public/index.html", "w", encoding="utf-8") as f:
        f.write(render(prs, digest_text))
    print(f"Wrote public/index.html with {len(prs)} PRs.")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

serve.py

The server is even smaller:

# serve.py
import functools
import http.server
import os

PORT = int(os.environ.get("PORT", "8000"))
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory="public")
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), handler)
print(f"Serving public/ on port {PORT}")
server.serve_forever()
Enter fullscreen mode Exit fullscreen mode

One cron line drives the whole thing, assuming the environment variables are available to the cron job:

0 9 * * 1 cd /opt/digest && python3 digest.py
Enter fullscreen mode Exit fullscreen mode

Where the free infrastructure came in

The summarization calls ran through MonkeyCode's free model access, and the demo was served from its free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source project. At the time of writing, its free tier included 10 million tokens, which is more than enough for a weekly digest of a small repository. Quotas change, so check the current docs before building a workflow on top of them. The free server served the static file with no configuration beyond setting a port.

The important detail is structural: the LLM call is isolated in one function, and the deployment is a plain static file. Swapping the provider or the host is a ten-minute change, not a rewrite. Free infrastructure was sufficient at every step, which is the real finding of this build.

Scope cut #2: what got skipped

  • Auth and multi-user. One repo, one page, zero logins. Adding accounts means sessions, storage, and a login flow — a week of work for zero added value in a personal tool.
  • Slack or Discord delivery. A webhook post is easy, but it adds a second integration and a second failure mode. The static page is enough.
  • On-demand refresh. The page regenerates on a schedule. A webhook-triggered rebuild is the obvious next step, and it was deliberately left out.
  • Tests beyond a smoke test. The script either writes a file or prints an error. That is the entire test plan for a weekend tool.
  • CI. The cron job is the CI.

Each cut removed a class of problems. No database means no migrations. No auth means no session bugs. No queue means no stuck jobs.

How to run it

export GITHUB_REPO=your/repo
export LLM_ENDPOINT=https://your-endpoint.example
export LLM_API_KEY=your-key
export LLM_MODEL=your-model
python3 digest.py
python3 serve.py
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8000 and the digest is there. The GitHub API call follows the official pulls endpoint, and the server is the standard library's http.server. No dependencies beyond Python 3.9+.

Limitations and who should not use this

The approach is honest about its edges:

  • Digest quality depends on PR titles and bodies. Sparse descriptions produce a sparse digest.
  • The example assumes a chat-completions-style response. Check the endpoint's docs and adjust the parsing if needed.
  • GitHub's unauthenticated API is rate-limited to 60 core requests per hour. Fine for one repo and a weekly job, not fine for a fleet of repos.
  • The GitHub call reads up to 30 closed PRs without pagination. A busier repo needs a loop over pages.
  • The page has no refresh button. Regeneration is the cron job's job.
  • 10 million tokens is generous for this workload but not infinite. A busy repo with long PR bodies will consume more, so monitor usage if the prompt grows.

Teams that need access control, audit logs, or on-demand generation should not use this pattern. It is a personal-tool architecture, not a product architecture.

What the build proved

The bottleneck was never the model or the server. It was the list of features that looked small on paper. Cutting the database, the auth, and the delivery channels turned a two-week estimate into a two-day demo.

The pattern is provider-agnostic; the free tier used here simply made the cost of trying it zero. One afternoon with the script above is enough to know whether a digest bot earns its place in your workflow.

Top comments (0)