From 93b2999db7219056431f56b5ce15199bb55daddf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:23:00 +0000 Subject: [PATCH 1/6] Initial plan From a5f5b019439b6208b50d8075803a7c0da154e320 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:27:46 +0000 Subject: [PATCH 2/6] Add weekly AI-driven repo description updater workflow and script Co-authored-by: paulpv <1393897+paulpv@users.noreply.github.com> Agent-Logs-Url: https://github.com/LookAtWhatAiCanDo/Describer/sessions/ae3a9b52-1691-412f-be1d-05d213658cf7 --- .../workflows/update-repo-descriptions.yml | 30 ++ .../update-repo-descriptions.cpython-312.pyc | Bin 0 -> 17929 bytes scripts/update-repo-descriptions.py | 482 ++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 .github/workflows/update-repo-descriptions.yml create mode 100644 scripts/__pycache__/update-repo-descriptions.cpython-312.pyc create mode 100644 scripts/update-repo-descriptions.py diff --git a/.github/workflows/update-repo-descriptions.yml b/.github/workflows/update-repo-descriptions.yml new file mode 100644 index 0000000..d797d8c --- /dev/null +++ b/.github/workflows/update-repo-descriptions.yml @@ -0,0 +1,30 @@ +name: Update Repo Descriptions + +on: + schedule: + # Every Monday at 07:00 UTC + - cron: '0 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + update-descriptions: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Update repo descriptions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_ADMIN_PAT: ${{ secrets.REPO_ADMIN_PAT }} + ORG_NAME: ${{ vars.ORG_NAME }} + GITHUB_MODEL: ${{ vars.GITHUB_MODEL }} + run: python scripts/update-repo-descriptions.py diff --git a/scripts/__pycache__/update-repo-descriptions.cpython-312.pyc b/scripts/__pycache__/update-repo-descriptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..534fc3e0e2330fe9e7887a53130a51ebc28db04b GIT binary patch literal 17929 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

_U2rbv%yPGe&y(|$#trJMcD_4_`&uqPwX)M zjm`T+v-NN4%@j6u*C6YM%@zuGIG*%aez>>hN#61!-h#Ah9~>lL861SrHiaJg{Cic~ z;2=yHgM%{JHksR~YJ{)Y0l<>#DtFk|JZ;>Ctb#9Ru?jvDfzFJ$E7ew@-x|hYSm@tV z-+xB|@tB%*m|-y+0X)1NIr(Q)03M!`zf8d^6wFcZbqcN{0Jcj2%MC$OSNdp2HPI)# zFH@awP#t_n9!fBYR5g2PM(+|ZKKKW&G;!&^7)x(hpk=dQ1A+I`Pu z@)@Tq?(wEdBi?vtjWNsl?loAo*lVxGyZ2r-SLvU2=dD(QF9XdzqPgz-6y+=JS{5S8 zLX@xN4HcQHJcnqm>OMvJhDw8PR>^aSGKu>X<=eSOj~H@0_uTBhXR0>r&9vk>MDu8e zXd%R+T*Ich>g&7nR3Z0Z$Blt+oxe}%e4xf~n2Z$H$MPJK^ZoZJ${(sl|L6hHT=ksz z4PZ(n^X=6JYi2+7Xq{snTj#}v{jY}aQ%*jx(SXlq^BkgiOaf7EbIV-U^~t=6(yjXz zS{9Urov(iKKE3B3H?$hI!Idg+LVTC6$XgI&UmG^&ZIrTe-s;(|OOtsAz40*oWK5a< zjQ#52ypwWVoYgtAf7Uc}=yQYjD=6nvhAoC|8B?A^G-tX`QU180%CL0~vqH3>MK`c< zc+2nMu?(yR(L7dz=qB1CdecmsZN1-o!hmIc(#V-zGpSpq4R4$6e{d8w6mtW+p8pqf C$zpo| literal 0 HcmV?d00001 diff --git a/scripts/update-repo-descriptions.py b/scripts/update-repo-descriptions.py new file mode 100644 index 0000000..157c7b7 --- /dev/null +++ b/scripts/update-repo-descriptions.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +""" +update-repo-descriptions.py + +Weekly script that crawls all repositories in a GitHub organisation and uses +an AI model to generate or update each repo's GitHub description. + +Available model IDs: https://github.com/marketplace/models +""" + +import base64 +import json +import os +import sys +import urllib.error +import urllib.request +from urllib.parse import urlencode + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# Model used for every AI call throughout the script. +# Override via the GITHUB_MODEL environment variable. +# See https://github.com/marketplace/models for available model IDs. +MODEL = os.environ.get("GITHUB_MODEL", "gpt-5-mini") + +GITHUB_API_BASE = "https://api.github.com" +MODELS_API_URL = "https://models.inference.ai.azure.com/chat/completions" + +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") +REPO_ADMIN_PAT = os.environ.get("REPO_ADMIN_PAT", "") +ORG_NAME = os.environ.get("ORG_NAME", "") + +# Maximum estimated tokens to send to the model per repo. +TOKEN_BUDGET = 100_000 +# Rough characters-per-token estimate (conservative for mixed content). +CHARS_PER_TOKEN = 4 +# Maximum individual file size to include. +MAX_FILE_BYTES = 50_000 + +# --------------------------------------------------------------------------- +# File-filtering rules +# --------------------------------------------------------------------------- + +EXCLUDED_DIRS = { + "node_modules", "vendor", ".git", "dist", "build", "out", + "__pycache__", ".next", ".cache", +} + +EXCLUDED_FILES = { + "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "Cargo.lock", "poetry.lock", +} + +BINARY_EXTENSIONS = { + # Images + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".tiff", + # Fonts + ".ttf", ".otf", ".woff", ".woff2", ".eot", + # Audio / video + ".mp3", ".mp4", ".wav", ".ogg", ".avi", ".mov", ".mkv", ".flac", + # Compiled / binary + ".exe", ".dll", ".so", ".dylib", ".class", ".pyc", ".pyo", + ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", +} + +SOURCE_EXTENSIONS = { + ".js", ".ts", ".jsx", ".tsx", ".py", ".go", ".rs", ".java", + ".rb", ".swift", ".kt", ".cs", ".cpp", ".c", ".h", ".sh", ".bash", + ".php", ".scala", ".r", ".m", ".lua", ".pl", ".ex", ".exs", + ".elm", ".clj", ".cljs", ".hs", ".ml", ".mli", ".fs", ".fsx", + ".vue", ".svelte", +} + +CONFIG_FILENAMES = { + "package.json", "pyproject.toml", "setup.py", "setup.cfg", + "Cargo.toml", "go.mod", "Gemfile", "Dockerfile", + "docker-compose.yml", "docker-compose.yaml", + ".env.example", "tsconfig.json", "jest.config.js", + "webpack.config.js", "vite.config.ts", "vite.config.js", + "babel.config.js", ".eslintrc.js", ".eslintrc.json", + "requirements.txt", "Makefile", "CMakeLists.txt", +} + +DOC_EXTENSIONS = {".md", ".rst", ".txt"} + + +def _is_excluded_path(path: str) -> bool: + """Return True if any path component is an excluded directory.""" + parts = path.replace("\\", "/").split("/") + for part in parts[:-1]: # directories only + if part in EXCLUDED_DIRS: + return True + return False + + +def _is_excluded_file(path: str) -> bool: + """Return True if the file itself should be excluded.""" + filename = path.split("/")[-1] + if filename in EXCLUDED_FILES: + return True + # Minified files + if filename.endswith(".min.js") or filename.endswith(".min.css"): + return True + return False + + +def _file_priority(path: str) -> int: + """Return priority for token-budget trimming (lower = higher priority).""" + filename = path.split("/")[-1] + ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else "" + if ext in DOC_EXTENSIONS: + return 1 + if filename in CONFIG_FILENAMES: + return 2 + if ext in {".yml", ".yaml"} and ".github/workflows" in path: + return 2 + return 3 # source files + + +def _is_relevant(path: str) -> bool: + """Return True if this file should be included in the prompt context.""" + if _is_excluded_path(path): + return False + if _is_excluded_file(path): + return False + filename = path.split("/")[-1] + ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else "" + if ext in BINARY_EXTENSIONS: + return False + if ext in DOC_EXTENSIONS: + return True + if filename in CONFIG_FILENAMES: + return True + if ext in {".yml", ".yaml"} and ".github/workflows" in path: + return True + if ext in SOURCE_EXTENSIONS: + return True + return False + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _github_request(path: str, *, token: str, method: str = "GET", + body: dict | None = None, params: dict | None = None): + """ + Make a GitHub REST API request and return the parsed JSON response. + Raises urllib.error.HTTPError on non-2xx responses. + """ + url = f"{GITHUB_API_BASE}{path}" + if params: + url += "?" + urlencode(params) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "describer-bot/1.0", + } + data = json.dumps(body).encode() if body is not None else None + if data: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + + +def _models_request(messages: list[dict]) -> str: + """ + Call the GitHub Models inference API and return the assistant's reply text. + """ + payload = { + "model": MODEL, + "messages": messages, + "temperature": 0.3, + "max_tokens": 512, + } + data = json.dumps(payload).encode() + headers = { + "Authorization": f"Bearer {GITHUB_TOKEN}", + "Content-Type": "application/json", + "User-Agent": "describer-bot/1.0", + } + req = urllib.request.Request( + MODELS_API_URL, data=data, headers=headers, method="POST" + ) + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read().decode()) + return result["choices"][0]["message"]["content"].strip() + + +# --------------------------------------------------------------------------- +# Core functions +# --------------------------------------------------------------------------- + +def fetch_org_repos(org: str) -> list[dict]: + """ + Return all non-archived, non-fork repositories in *org* (paginated). + Uses REPO_ADMIN_PAT for authentication so that private repos are visible. + """ + token = REPO_ADMIN_PAT or GITHUB_TOKEN + repos = [] + page = 1 + while True: + page_data = _github_request( + f"/orgs/{org}/repos", + token=token, + params={"per_page": 100, "page": page, "type": "all"}, + ) + if not page_data: + break + for repo in page_data: + if not repo.get("archived") and not repo.get("fork"): + repos.append(repo) + if len(page_data) < 100: + break + page += 1 + return repos + + +def fetch_file_tree(owner: str, repo: str) -> list[str]: + """ + Return a flat list of all file paths in *repo* via the recursive tree API. + """ + token = REPO_ADMIN_PAT or GITHUB_TOKEN + try: + data = _github_request( + f"/repos/{owner}/{repo}/git/trees/HEAD", + token=token, + params={"recursive": "1"}, + ) + except urllib.error.HTTPError as exc: + if exc.code == 409: + # Empty repository + return [] + raise + return [ + item["path"] + for item in data.get("tree", []) + if item.get("type") == "blob" + ] + + +def fetch_file_contents(owner: str, repo: str, + paths: list[str]) -> dict[str, str]: + """ + Fetch the decoded text contents of *paths* in *repo*. + Files larger than MAX_FILE_BYTES or that cannot be decoded as UTF-8 are + skipped with a log note. + Returns a dict mapping path → content string. + """ + token = REPO_ADMIN_PAT or GITHUB_TOKEN + contents = {} + for path in paths: + try: + data = _github_request( + f"/repos/{owner}/{repo}/contents/{path}", + token=token, + ) + size = data.get("size", 0) + if size > MAX_FILE_BYTES: + print(f" [skip] {path} — {size:,} bytes > {MAX_FILE_BYTES:,} limit") + continue + raw = base64.b64decode(data.get("content", "").replace("\n", "")) + decoded = raw.decode("utf-8", errors="replace") + if "\ufffd" in decoded: + print(f" [warn] {path} contains non-UTF-8 bytes; some characters replaced") + contents[path] = decoded + except urllib.error.HTTPError as exc: + print(f" [warn] Could not fetch {path}: HTTP {exc.code}") + except Exception as exc: # noqa: BLE001 + print(f" [warn] Could not fetch {path}: {exc}") + return contents + + +def build_prompt_context( + owner: str, + repo: str, + current_description: str, + all_paths: list[str], + file_contents: dict[str, str], +) -> tuple[str, int]: + """ + Assemble the prompt context string, respecting the token budget. + Priority order: docs > config/manifests > source files. + """ + tree_listing = "\n".join(all_paths) + + # Sort included files by priority so we can truncate low-priority ones first. + included_paths = sorted(file_contents.keys(), key=_file_priority) + + # Calculate tree budget (always fully included). + tree_tokens = len(tree_listing) // CHARS_PER_TOKEN + remaining_budget = TOKEN_BUDGET - tree_tokens + + sections = [] + used_tokens = 0 + for path in included_paths: + text = file_contents[path] + tokens = len(text) // CHARS_PER_TOKEN + if used_tokens + tokens > remaining_budget: + # Truncate to fit + allowed_chars = (remaining_budget - used_tokens) * CHARS_PER_TOKEN + if allowed_chars <= 0: + break + text = text[:allowed_chars] + "\n... [truncated]" + sections.append(f"### {path}\n{text}") + break + sections.append(f"### {path}\n{text}") + used_tokens += tokens + + file_contents_block = "\n\n".join(sections) + estimated_tokens = (tree_tokens + used_tokens) + + context = ( + f"Current description (may be empty): {current_description}\n\n" + f"Repository: {owner}/{repo}\n\n" + f"File tree:\n{tree_listing}\n\n" + f"File contents:\n{file_contents_block}" + ) + return context, estimated_tokens + + +def call_model(context: str) -> str: + """ + Ask the AI model to generate a one-sentence repository description. + Returns the raw string from the model. + """ + messages = [ + { + "role": "system", + "content": ( + "You are a technical writer who specializes in writing " + "GitHub repository descriptions." + ), + }, + { + "role": "user", + "content": ( + "Analyse the following repository content and write a GitHub " + "repository description.\n\n" + "Rules:\n" + "- One sentence, maximum 350 characters\n" + "- Explain what the project actually does (not what it aspires to do)\n" + "- Mention the main technology or platform if it helps clarify the purpose\n" + "- If it is a browser extension, CLI tool, library, service, app, or " + "framework, say so directly\n" + "- Avoid vague marketing language (\"powerful\", \"seamless\", " + "\"easy-to-use\") unless no technical alternative exists\n" + "- Do not start with the repo name\n" + "- Return ONLY the description string — no quotes, no explanation, " + "no preamble\n\n" + + context + ), + }, + ] + return _models_request(messages) + + +def check_semantically_equal(existing: str, generated: str) -> bool: + """ + Ask the model whether two descriptions convey the same meaning. + Returns True if the model answers YES (skip update). + """ + if not existing: + return False + messages = [ + { + "role": "user", + "content": ( + "Do these two GitHub repo descriptions convey the same meaning? " + "Answer only YES or NO.\n" + f"A: {existing}\n" + f"B: {generated}" + ), + } + ] + answer = _models_request(messages).strip().upper() + return answer.startswith("YES") + + +def update_repo_description(owner: str, repo: str, description: str) -> None: + """ + Update the repository description via PATCH /repos/{owner}/{repo}. + Uses REPO_ADMIN_PAT which must have the `repo` scope. + """ + _github_request( + f"/repos/{owner}/{repo}", + token=REPO_ADMIN_PAT, + method="PATCH", + body={"description": description}, + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + if not ORG_NAME: + print("ERROR: ORG_NAME environment variable is not set.", file=sys.stderr) + sys.exit(1) + if not GITHUB_TOKEN: + print("ERROR: GITHUB_TOKEN environment variable is not set.", file=sys.stderr) + sys.exit(1) + if not REPO_ADMIN_PAT: + print( + "WARNING: REPO_ADMIN_PAT is not set — description updates will fail.", + file=sys.stderr, + ) + + print(f"Organisation: {ORG_NAME}") + print(f"Model: {MODEL}") + print() + + repos = fetch_org_repos(ORG_NAME) + print(f"Found {len(repos)} non-archived, non-fork repos.\n") + + total = len(repos) + updated = 0 + skipped = 0 + failed = 0 + + for repo_data in repos: + owner = repo_data["owner"]["login"] + repo_name = repo_data["name"] + prefix = f"[{owner}/{repo_name}]" + current_description = repo_data.get("description") or "" + + try: + # 1. Fetch file tree + print(f"{prefix} Fetching file tree...", end=" ", flush=True) + all_paths = fetch_file_tree(owner, repo_name) + relevant_paths = [p for p in all_paths if _is_relevant(p)] + print(f"{len(all_paths)} files found, {len(relevant_paths)} included") + + # 2. Fetch file contents + file_contents = fetch_file_contents(owner, repo_name, relevant_paths) + + # 3. Build prompt context + context, estimated_tokens = build_prompt_context( + owner, repo_name, current_description, all_paths, file_contents + ) + print(f"{prefix} Context: {estimated_tokens:,} tokens (estimated)") + + # 4. Call model + generated = call_model(context) + # Sanitise: strip surrounding quotes if the model added them + generated = generated.strip().strip('"').strip("'").strip() + print(f'{prefix} Generated: "{generated}"') + print(f'{prefix} Existing: "{current_description}"') + + # 5. Semantic similarity check + if check_semantically_equal(current_description, generated): + print(f"{prefix} Semantic check: SAME → skipping") + skipped += 1 + else: + action = "updating" if current_description else "setting" + print(f"{prefix} Semantic check: DIFFERENT → {action}") + update_repo_description(owner, repo_name, generated) + print(f"{prefix} ✓ Updated") + updated += 1 + + except Exception as exc: # noqa: BLE001 + print(f"{prefix} ✗ Failed: {exc}") + failed += 1 + + print() + + # Summary table + print("─" * 60) + print( + f"Summary: {total} repos | {updated} updated | " + f"{skipped} skipped | {failed} failed" + ) + + +if __name__ == "__main__": + main() From b52a18a4c9b4c0f1060755ad46e8bbaad162e66e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:28:03 +0000 Subject: [PATCH 3/6] Remove __pycache__ and add .gitignore Co-authored-by: paulpv <1393897+paulpv@users.noreply.github.com> Agent-Logs-Url: https://github.com/LookAtWhatAiCanDo/Describer/sessions/ae3a9b52-1691-412f-be1d-05d213658cf7 --- .gitignore | 2 ++ .../update-repo-descriptions.cpython-312.pyc | Bin 17929 -> 0 bytes 2 files changed, 2 insertions(+) create mode 100644 .gitignore delete mode 100644 scripts/__pycache__/update-repo-descriptions.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/scripts/__pycache__/update-repo-descriptions.cpython-312.pyc b/scripts/__pycache__/update-repo-descriptions.cpython-312.pyc deleted file mode 100644 index 534fc3e0e2330fe9e7887a53130a51ebc28db04b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17929 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

_U2rbv%yPGe&y(|$#trJMcD_4_`&uqPwX)M zjm`T+v-NN4%@j6u*C6YM%@zuGIG*%aez>>hN#61!-h#Ah9~>lL861SrHiaJg{Cic~ z;2=yHgM%{JHksR~YJ{)Y0l<>#DtFk|JZ;>Ctb#9Ru?jvDfzFJ$E7ew@-x|hYSm@tV z-+xB|@tB%*m|-y+0X)1NIr(Q)03M!`zf8d^6wFcZbqcN{0Jcj2%MC$OSNdp2HPI)# zFH@awP#t_n9!fBYR5g2PM(+|ZKKKW&G;!&^7)x(hpk=dQ1A+I`Pu z@)@Tq?(wEdBi?vtjWNsl?loAo*lVxGyZ2r-SLvU2=dD(QF9XdzqPgz-6y+=JS{5S8 zLX@xN4HcQHJcnqm>OMvJhDw8PR>^aSGKu>X<=eSOj~H@0_uTBhXR0>r&9vk>MDu8e zXd%R+T*Ich>g&7nR3Z0Z$Blt+oxe}%e4xf~n2Z$H$MPJK^ZoZJ${(sl|L6hHT=ksz z4PZ(n^X=6JYi2+7Xq{snTj#}v{jY}aQ%*jx(SXlq^BkgiOaf7EbIV-U^~t=6(yjXz zS{9Urov(iKKE3B3H?$hI!Idg+LVTC6$XgI&UmG^&ZIrTe-s;(|OOtsAz40*oWK5a< zjQ#52ypwWVoYgtAf7Uc}=yQYjD=6nvhAoC|8B?A^G-tX`QU180%CL0~vqH3>MK`c< zc+2nMu?(yR(L7dz=qB1CdecmsZN1-o!hmIc(#V-zGpSpq4R4$6e{d8w6mtW+p8pqf C$zpo| From ad8a897622bc07a7c55dc7292f06e21ab22e81b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:39:49 +0000 Subject: [PATCH 4/6] Rename GITHUB_MODEL to AI_MODEL (GitHub disallows GITHUB_ prefix for custom vars) Co-authored-by: paulpv <1393897+paulpv@users.noreply.github.com> Agent-Logs-Url: https://github.com/LookAtWhatAiCanDo/Describer/sessions/233f0607-ddaf-46c2-8580-4756144ff68f --- .github/workflows/update-repo-descriptions.yml | 2 +- scripts/update-repo-descriptions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-repo-descriptions.yml b/.github/workflows/update-repo-descriptions.yml index d797d8c..d537e73 100644 --- a/.github/workflows/update-repo-descriptions.yml +++ b/.github/workflows/update-repo-descriptions.yml @@ -26,5 +26,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO_ADMIN_PAT: ${{ secrets.REPO_ADMIN_PAT }} ORG_NAME: ${{ vars.ORG_NAME }} - GITHUB_MODEL: ${{ vars.GITHUB_MODEL }} + AI_MODEL: ${{ vars.AI_MODEL }} run: python scripts/update-repo-descriptions.py diff --git a/scripts/update-repo-descriptions.py b/scripts/update-repo-descriptions.py index 157c7b7..8958fab 100644 --- a/scripts/update-repo-descriptions.py +++ b/scripts/update-repo-descriptions.py @@ -21,9 +21,9 @@ # --------------------------------------------------------------------------- # Model used for every AI call throughout the script. -# Override via the GITHUB_MODEL environment variable. +# Override via the AI_MODEL environment variable. # See https://github.com/marketplace/models for available model IDs. -MODEL = os.environ.get("GITHUB_MODEL", "gpt-5-mini") +MODEL = os.environ.get("AI_MODEL", "gpt-5-mini") GITHUB_API_BASE = "https://api.github.com" MODELS_API_URL = "https://models.inference.ai.azure.com/chat/completions" From 5f799e23c4819e4265311a5af2f9dd840f9af973 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:43:36 +0000 Subject: [PATCH 5/6] Add comprehensive README with setup, env vars, PAT, and usage docs Co-authored-by: paulpv <1393897+paulpv@users.noreply.github.com> Agent-Logs-Url: https://github.com/LookAtWhatAiCanDo/Describer/sessions/d807444a-71c7-4ce4-8244-00c617b6c88c --- README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 66c3f7c..9049bff 100644 --- a/README.md +++ b/README.md @@ -1 +1,121 @@ -# Describer \ No newline at end of file +# Describer + +A GitHub Actions workflow and Python script that automatically generates and updates GitHub repository descriptions for every non-archived, non-fork repo in your organisation using an AI model from [GitHub Models](https://github.com/marketplace/models). + +## How it works + +1. **Crawls** all non-archived, non-fork repos in the configured GitHub org. +2. **Reads** each repo's file tree and fetches the content of relevant files (docs, config manifests, source files), capped at a 100 k-token budget. +3. **Asks** an AI model to write a one-sentence description of the repo. +4. **Checks** whether the generated description is semantically equivalent to the existing one — if yes, it skips the update. +5. **Updates** the repo description via the GitHub API when a meaningful change is detected. + +The script runs entirely on Python standard library — no third-party dependencies. + +## Workflow schedule + +The workflow (`.github/workflows/update-repo-descriptions.yml`) is triggered: + +- **Automatically** every Monday at 07:00 UTC (`0 7 * * 1`). +- **Manually** via `workflow_dispatch` from the Actions tab. + +## Setup + +### 1. Fork or copy this repository into your organisation + +The workflow must live in a repository that GitHub Actions can execute. + +### 2. Create a Personal Access Token (PAT) with repo admin rights + +The `GITHUB_TOKEN` provided automatically by Actions can read repos but **cannot update another repository's description** via `PATCH /repos/{owner}/{repo}`. You need a separate PAT that has the `repo` scope (classic PAT) or `repository > metadata > write` (fine-grained PAT) for every repo whose description you want to update. + +1. Go to **GitHub → Settings → Developer settings → Personal access tokens**. +2. Create a token with at minimum the `repo` scope (classic) or `repository metadata: read/write` (fine-grained, scoped to the target org). +3. Copy the token value — you will only see it once. + +### 3. Configure secrets and variables + +In the repository that hosts this workflow go to **Settings → Secrets and variables → Actions**. + +#### Secrets + +| Name | Required | Description | +|---|---|---| +| `REPO_ADMIN_PAT` | **Yes** | PAT with `repo` scope used to update repo descriptions (see step 2). | + +> `GITHUB_TOKEN` is provided automatically by GitHub Actions — you do **not** need to create it. + +#### Variables + +| Name | Required | Default | Description | +|---|---|---|---| +| `ORG_NAME` | **Yes** | — | The GitHub organisation whose repos will be processed (e.g. `my-org`). | +| `AI_MODEL` | No | `gpt-5-mini` | GitHub Models model ID to use for description generation. See [GitHub Marketplace models](https://github.com/marketplace/models) for available IDs. | + +> **Note:** GitHub does not allow variable names that start with `GITHUB_`. That is why the model variable is named `AI_MODEL` rather than `GITHUB_MODEL`. + +### 4. Enable GitHub Models access + +The script calls the [GitHub Models inference API](https://models.inference.ai.azure.com) using the workflow's built-in `GITHUB_TOKEN`. Ensure your organisation has access to GitHub Models (currently available to organisations on GitHub Teams / Enterprise or via the public beta). + +## Environment variables (script reference) + +The Python script reads the following environment variables at runtime: + +| Variable | Source | Description | +|---|---|---| +| `GITHUB_TOKEN` | `secrets.GITHUB_TOKEN` (automatic) | Authenticates GitHub API reads and GitHub Models API calls. | +| `REPO_ADMIN_PAT` | `secrets.REPO_ADMIN_PAT` | Authenticates repo description `PATCH` calls. Must have `repo` scope. | +| `ORG_NAME` | `vars.ORG_NAME` | GitHub organisation to crawl. | +| `AI_MODEL` | `vars.AI_MODEL` | GitHub Models model ID (default: `gpt-5-mini`). | + +## Running locally + +```bash +export GITHUB_TOKEN="ghp_..." # token with read:org + repo scopes +export REPO_ADMIN_PAT="ghp_..." # token with repo scope for PATCH calls +export ORG_NAME="my-org" +export AI_MODEL="gpt-5-mini" # optional + +python scripts/update-repo-descriptions.py +``` + +## Example output + +``` +Organisation: my-org +Model: gpt-5-mini + +Found 24 non-archived, non-fork repos. + +[my-org/repo-name] Fetching file tree... 312 files found, 48 included +[my-org/repo-name] Context: 12,400 tokens (estimated) +[my-org/repo-name] Generated: "A Node.js CLI tool that scaffolds Firebase projects..." +[my-org/repo-name] Existing: "Firebase scaffolding tool" +[my-org/repo-name] Semantic check: DIFFERENT → updating +[my-org/repo-name] ✓ Updated + +──────────────────────────────────────────────────────────── +Summary: 24 repos | 6 updated | 17 skipped | 1 failed +``` + +## File-filtering rules + +The script includes only files that are relevant to understanding what a repo does: + +- **Docs:** `*.md`, `*.rst`, `*.txt` +- **Config manifests:** `package.json`, `Cargo.toml`, `go.mod`, `Dockerfile`, `requirements.txt`, etc. +- **CI workflows:** `.github/workflows/*.yml` +- **Source code:** `.js`, `.ts`, `.py`, `.go`, `.rs`, `.java`, and many more + +The following are always excluded: + +- Directories: `node_modules/`, `vendor/`, `dist/`, `build/`, `.git/`, etc. +- Lock files: `package-lock.json`, `yarn.lock`, `Cargo.lock`, etc. +- Binary and media files (images, fonts, audio, video, compiled artifacts) +- Minified files (`*.min.js`, `*.min.css`) +- Files larger than 50 KB + +## License + +See [LICENSE](LICENSE). From bc4aac0c01ec0aa83de43cee25a3606669c4fd81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:03:19 +0000 Subject: [PATCH 6/6] Fix PAT scope wording in README setup instructions Co-authored-by: paulpv <1393897+paulpv@users.noreply.github.com> Agent-Logs-Url: https://github.com/LookAtWhatAiCanDo/Describer/sessions/da45ef91-5785-4aaa-9fe7-db8186f0771e --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9049bff..065fe9c 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ The workflow must live in a repository that GitHub Actions can execute. ### 2. Create a Personal Access Token (PAT) with repo admin rights -The `GITHUB_TOKEN` provided automatically by Actions can read repos but **cannot update another repository's description** via `PATCH /repos/{owner}/{repo}`. You need a separate PAT that has the `repo` scope (classic PAT) or `repository > metadata > write` (fine-grained PAT) for every repo whose description you want to update. +The `GITHUB_TOKEN` provided automatically by Actions can read repos but **cannot update another repository's description** via `PATCH /repos/{owner}/{repo}`. You need a separate PAT scoped to the target org. 1. Go to **GitHub → Settings → Developer settings → Personal access tokens**. -2. Create a token with at minimum the `repo` scope (classic) or `repository metadata: read/write` (fine-grained, scoped to the target org). +2. Create a token with at minimum the classic `repo` scope or the fine-grained `repository : content: read/write` (scoped to the target org). 3. Copy the token value — you will only see it once. ### 3. Configure secrets and variables