$npx -y skills add Varnan-Tech/opendirectory --skill gh-issue-to-demand-signalTakes a competitor's public GitHub repo URL, fetches their open issues via the GitHub REST API, filters noise locally, clusters issues into 6 demand categories, computes a demand score per issue and per cluster, and outputs a ranked demand gap report with a GTM messaging brief. U
| 1 | # GitHub Issue Demand Signal |
| 2 | |
| 3 | Take a competitor's public GitHub repo. Fetch their open issues. Filter noise locally. Cluster into 6 demand categories. Score by real engagement. Output a ranked demand gap report and GTM messaging brief. |
| 4 | |
| 5 | --- |
| 6 | |
| 7 | **Critical rule:** Every issue title in the output must be verbatim from the GitHub API response. Every cluster theme name must be derived from actual issue titles in that cluster. If fewer than 10 issues remain after noise filtering, stop and tell the user -- the repo is too small for reliable clustering. No invented issue content anywhere. |
| 8 | |
| 9 | --- |
| 10 | |
| 11 | ## Common Mistakes |
| 12 | |
| 13 | | The agent will want to... | Why that's wrong | |
| 14 | |---|---| |
| 15 | | Send all 200 raw issues to the AI without filtering | Bot issues, PRs, and zero-engagement noise inflate cluster counts and waste context. Filter locally first. | |
| 16 | | Use comment count as the primary demand signal | Comments include maintainer responses, off-topic discussion, and spam. reactions["+1"] is the cleanest buyer signal. | |
| 17 | | Paraphrase issue titles when summarizing clusters | Paraphrasing loses the buyer's exact language, which is the entire point. Use verbatim issue titles. | |
| 18 | | Continue past Step 4 if fewer than 10 issues remain after filtering | Under 10 issues means the repo is too small or the wrong URL was given. Clustering on sparse data produces meaningless categories. | |
| 19 | | Include pull requests in the analysis | The GitHub Issues endpoint returns PRs too. Filter by checking that the pull_request key is absent on the issue object. | |
| 20 | | Mark an issue as ignored demand without checking all 3 criteria | All three must be true: reactions >= 10, age >= 180 days, no planned/in-progress/roadmap label. Missing one criterion disqualifies the issue. | |
| 21 | |
| 22 | --- |
| 23 | |
| 24 | ## Step 1: Setup Check |
| 25 | |
| 26 | ```bash |
| 27 | echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-not set, unauthenticated rate limit applies (60 req/hr)}" |
| 28 | ``` |
| 29 | |
| 30 | **If GITHUB_TOKEN is not set:** Continue. Tell the user: "GITHUB_TOKEN is not set. Unauthenticated rate limit is 60 requests/hour -- enough for 2 fetches before hitting the limit. For repeated use, add a token at github.com/settings/tokens (no scopes needed for public repos)." |
| 31 | |
| 32 | --- |
| 33 | |
| 34 | ## Step 2: Gather Input |
| 35 | |
| 36 | You need: |
| 37 | - GitHub repo URL (e.g. https://github.com/owner/repo) or owner/repo slug (e.g. facebook/react) |
| 38 | |
| 39 | Parse owner and repo from input: |
| 40 | |
| 41 | ```bash |
| 42 | python3 << 'PYEOF' |
| 43 | import re, sys, os |
| 44 | |
| 45 | raw = "REPO_INPUT_HERE" |
| 46 | |
| 47 | # Normalize to owner/repo |
| 48 | if raw.startswith("http"): |
| 49 | m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw) |
| 50 | if not m: |
| 51 | print("ERROR: Could not parse GitHub URL. Expected format: https://github.com/owner/repo") |
| 52 | sys.exit(1) |
| 53 | owner, repo = m.group(1), m.group(2).rstrip("/") |
| 54 | elif "/" in raw: |
| 55 | parts = raw.strip().split("/") |
| 56 | owner, repo = parts[0], parts[1] |
| 57 | else: |
| 58 | print("ERROR: Input must be a GitHub URL or owner/repo slug (e.g. vercel/next.js)") |
| 59 | sys.exit(1) |
| 60 | |
| 61 | print(f"Owner: {owner}") |
| 62 | print(f"Repo: {repo}") |
| 63 | |
| 64 | with open("/tmp/ghd-target.txt", "w") as f: |
| 65 | f.write(f"{owner}/{repo}") |
| 66 | PYEOF |
| 67 | ``` |
| 68 | |
| 69 | **If parsing fails:** Stop. Ask: "Please provide the GitHub repo as a URL (https://github.com/owner/repo) or an owner/repo slug (e.g. vercel/next.js)." |
| 70 | |
| 71 | --- |
| 72 | |
| 73 | ## Step 3: Fetch Issues from GitHub REST API |
| 74 | |
| 75 | Fetch up to 200 issues (2 pages of 100). Check rate limit after the first fetch. |
| 76 | |
| 77 | ```bash |
| 78 | python3 << 'PYEOF' |
| 79 | import json, urllib.request, os, sys |
| 80 | from datetime import datetime, timezone |
| 81 | |
| 82 | target = open("/tmp/ghd-target.txt").read().strip() |
| 83 | owner_repo = target |
| 84 | token = os.environ.get("GITHUB_TOKEN", "") |
| 85 | |
| 86 | headers = {"Accept": "application/vnd.github+json", "User-Agent": "gh-issue-demand-signal/1.0"} |
| 87 | if token: |
| 88 | headers["Authorization"] = f"Bearer {token}" |
| 89 | |
| 90 | all_issues = [] |
| 91 | rate_limit_hit = False |
| 92 | |
| 93 | for page in [1, 2]: |
| 94 | url = f"https://api.github.com/repos/{owner_repo}/issues?state=open&per_page=100&page={page}" |
| 95 | req = urllib.request.Request(url, headers=headers) |
| 96 | |
| 97 | try: |
| 98 | with urllib.request.urlopen(req, timeout=30) as resp: |
| 99 | # Check rate limit after first page |
| 100 | if page == 1: |
| 101 | remaining = int(resp.headers.get("X-RateLimit-Remaining", 999)) |
| 102 | reset_ts = resp.headers.get( |