Sinal de Demanda de Issues do GitHub
Pegue um repositório público de um concorrente. Busque suas issues abertas. Filtre ruídos localmente. Agrupe em 6 categorias de demanda. Pontue pelo engajamento real. Gere um relatório ranqueado de lacunas de demanda e um briefing de mensagens GTM.
Regra crítica: Todo título de issue na saída deve estar literalmente como veio da resposta da API do GitHub. Cada nome de tema de cluster deve ser derivado dos títulos reais de issues naquele cluster. Se sobrarem menos de 10 issues após a filtragem de ruído, pare e avise o usuário -- o repositório é pequeno demais para um agrupamento confiável. Nenhum conteúdo de issue inventado em lugar algum.
Erros Comuns
| O agente vai querer... | Por que isso está errado |
|---|---|
| Enviar todas as 200 issues brutas para a IA sem filtrar | Issues de bots, PRs e ruídos com zero engajamento inflam as contagens dos clusters e desperdiçam contexto. Filtre localmente primeiro. |
| Usar o número de comentários como o principal sinal de demanda | Comentários incluem respostas de mantenedores, discussões fora do tópico e spam. reactions["+1"] é o sinal de comprador mais limpo. |
| Parafrasear títulos de issues ao resumir clusters | Parafrasear perde a linguagem exata do comprador, que é o objetivo central. Use títulos de issues literais. |
| Continuar após o Passo 4 se sobrarem menos de 10 issues após a filtragem | Menos de 10 issues significa que o repositório é pequeno demais ou a URL errada foi fornecida. Agrupar dados esparsos produz categorias sem sentido. |
| Incluir pull requests na análise | O endpoint de Issues do GitHub também retorna PRs. Filtre verificando se a chave pull_request está ausente no objeto da issue. |
| Marcar uma issue como demanda ignorada sem verificar todos os 3 critérios | Todos os três devem ser verdadeiros: reactions >= 10, age >= 180 dias, sem label de planejado/em andamento/roadmap. Perder um critério desqualifica a issue. |
Passo 1: Verificação de Configuração
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-não definido, limite de taxa não autenticado se aplica (60 req/h)}"
Se GITHUB_TOKEN não estiver definido: Continue. Informe ao usuário: "GITHUB_TOKEN não está definido. O limite de taxa não autenticado é de 60 requisições/hora -- suficiente para 2 buscas antes de atingir o limite. Para uso repetido, adicione um token em github.com/settings/tokens (nenhuma permissão necessária para repositórios públicos)."
Passo 2: Coletar Entrada
Você precisa de:
- URL do repositório GitHub (ex.: https://github.com/owner/repo) ou slug owner/repo (ex.: facebook/react)
Analise owner e repo da entrada:
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normalizar para owner/repo
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERROR: Could not parse GitHub URL. Expected format: https://github.com/owner/repo")
sys.exit(1)
owner, repo = m.group(1), m.group(2).rstrip("/")
elif "/" in raw:
parts = raw.strip().split("/")
owner, repo = parts[0], parts[1]
else:
print("ERROR: Input must be a GitHub URL or owner/repo slug (e.g. vercel/next.js)")
sys.exit(1)
print(f"Owner: {owner}")
print(f"Repo: {repo}")
with open("/tmp/ghd-target.txt", "w") as f:
f.write(f"{owner}/{repo}")
PYEOF
Se a análise falhar: Pare. Pergunte: "Por favor, forneça o repositório GitHub como uma URL (https://github.com/owner/repo) ou um slug owner/repo (ex.: vercel/next.js)."
Passo 3: Buscar Issues da API REST do GitHub
Busque até 200 issues (2 páginas de 100). Verifique o limite de taxa após a primeira busca.
python3 << 'PYEOF'
import json, urllib.request, os, sys
from datetime import datetime, timezone
target = open("/tmp/ghd-target.txt").read().strip()
owner_repo = target
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"Accept": "application/vnd.github+json", "User-Agent": "gh-issue-demand-signal/1.0"}
if token:
headers["Authorization"] = f"Bearer {token}"
all_issues = []
rate_limit_hit = False
for page in [1, 2]:
url = f"https://api.github.com/repos/{owner_repo}/issues?state=open&per_page=100&page={page}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
# Verificar limite de taxa após a primeira página
if page == 1:
remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
reset_ts = resp.headers.get("X-RateLimit-Reset", "")
if remaining == 0:
reset_str = datetime.fromtimestamp(int(reset_ts), tz=timezone.utc).strftime("%H:%M UTC") if reset_ts else "desconhecido"
print(f"ERROR: GitHub rate limit exhausted. Resets at {reset_str}.")
print("Add GITHUB_TOKEN to your .env file to get 5000 req/hr. See github.com/settings/tokens (no scopes needed).")
sys.exit(1)
print(f"Rate limit remaining: {remaining}")
# Verificar 404/403
status = resp.status
if status == 404:
print(f"ERROR: Repo '{owner_repo}' not found. Check the URL or slug.")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Page {page}: empty, stopping.")
break
all_issues.extend(page_data)
print(f"Page {page}: {len(page_data)} issues fetched")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERROR: Repo '{owner_repo}' not found (404). Check the URL or slug.")
elif e.code == 403:
print(f"ERROR: Access denied (403). Repo may be private or rate limit hit.")
else:
print(f"ERROR: GitHub API returned HTTP {e.code}")
sys.exit(1)
except Exception as e:
print(f"ERROR: Failed to fetch page {page}: {e}")
sys.exit(1)
print(f"Total raw issues fetched: {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
Se o GitHub retornar 404: Pare. Informe ao usuário: "Repositório não encontrado. Verifique a URL ou slug e tente novamente. Repositórios privados não são acessíveis sem autenticação e permissão explícita de repo."
Se o GitHub retornar 403 com cabeçalho de limite de taxa: Pare. Mostre o horário de redefinição e informe ao usuário para adicionar GITHUB_TOKEN.
Passo 4: Pré-Processar Localmente -- Filtrar, Pontuar, Detectar Demanda Ignorada
Nenhuma chamada de API. Python puro. Execute antes de qualquer coisa ir para a IA.
python3 << 'PYEOF'
import json, re
from datetime import datetime, timezone
raw = json.load(open("/tmp/ghd-raw-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
now = datetime.now(tz=timezone.utc)
noise_patterns = re.compile(
r"^(chore|deps|bump|renovate|dependabot|release|ci|build|revert)[\s:\[]",
re.IGNORECASE
)
pr_title_patterns = re.compile(
r"^(feat|fix|refactor|docs|test|style|perf|chore)(\(.+\))?:",
re.IGNORECASE
)
filtered = []
noise_count = 0
noise_reasons = {}
for issue in raw:
# Pular pull requests (o endpoint de Issues do GitHub também retorna PRs)
if "pull_request" in issue:
noise_count += 1
noise_reasons["pull_request"] = noise_reasons.get("pull_request", 0) + 1
continue
title = issue.get("title", "")
reactions = issue.get("reactions", {}).get("+1", 0)
comments = issue.get("comments", 0)
user_type = (issue.get("user") or {}).get("type", "User")
# Pular issues criadas por bots
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# Pular títulos com padrão de bot
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# Pular títulos de PR como issue
if pr_title_patterns.match(title):
noise_count += 1
noise_reasons["pr_as_issue"] = noise_reasons.get("pr_as_issue", 0) + 1
continue
# Pular issues com zero sinal
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# Calcular pontuação de demanda
demand_score = (reactions * 2) + (comments * 0.5)
# Detectar demanda ignorada
created_at = issue.get("created_at", "")
if created_at:
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
age_days = (now - created).days
else:
age_days = 0
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
has_planned_label = any(
kw in label for label in labels
for kw in ["in-progress", "planned", "roadmap", "wip", "in progress"]
)
ignored_demand = (
reactions >= 10 and
age_days >= 180 and
not has_planned_label
)
filtered.append({
"number": issue["number"],
"title": title,
"url": issue.get("html_url", f"https://github.com/{target}/issues/{issue['number']}"),
"reactions_plus1": reactions,
"comments": comments,
"demand_score": demand_score,
"age_days": age_days,
"labels": labels,
"ignored_demand": ignored_demand,
"body_snippet": (issue.get("body") or "")[:300]
})
# Ordenar por pontuação de demanda decrescente
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"Raw issues: {len(raw)}")
print(f"Noise filtered: {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"Issues for analysis: {len(filtered)}")
if len(filtered) < 10:
print(f"ERROR: Only {len(filtered)} issues remain after filtering. This repo has too few engaged issues for reliable clustering.")
print("Try a larger repo or a repo with more community engagement.")
import sys; sys.exit(1)
# Resumo de demanda ignorada
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"Ignored demand issues: {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("Pre-processing complete.")
PYEOF
Se sobrarem menos de 10 issues após a filtragem: Pare. Informe ao usuário exatamente quantas issues foram encontradas e filtradas, e por que o repositório é pequeno demais para um agrupamento de demanda confiável.
Passo 5: Agrupar Issues
Imprima as issues filtradas para análise:
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
target = open("/tmp/ghd-target.txt").read().strip()
issue_list = filtered[:150]
print(f"Repo: {target}")
print(f"Issues to cluster: {len(issue_list)}")
print()
for i in issue_list:
labels_str = f" [{', '.join(i['labels'][:3])}]" if i['labels'] else ""
print(f"#{i['number']} [score:{round(i['demand_score'],1)} reactions:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
Classifique cada issue impressa acima em uma das seguintes 6 categorias:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
Regras:
- Classifique cada issue em exatamente uma categoria
- Extraia uma declaração de dor de 1 frase usando a linguagem exata do usuário do título -- não parafraseie
- Identifique 5-8 temas de cluster: frases curtas (3-6 palavras) capturando padrões de reclamação dominantes em todas as issues
- Sem travessões. Sem linguagem de marketing.
Escreva sua análise em /tmp/ghd-clusters.json com esta estrutura exata:
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "Users need X which does not exist yet"}
],
"cluster_themes": [
{"theme_name": "Missing export options", "category": "feature_gap", "issue_numbers": [123, 456, 789]}
],
"category_counts": {"feature_gap": 5, "bug_pattern": 3, "ux_complaint": 4, "performance": 2, "integration_missing": 6, "docs_missing": 1}
}
Após escrever o arquivo, confirme com:
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'Classified: {len(d[\"classified_issues\"])} issues, {len(d[\"cluster_themes\"])} themes')
print('Categories:', d['category_counts'])
"
Passo 6: Briefing de Mensagens
Calcule a pontuação de demanda total por cluster e imprima os 3 principais:
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
clusters = json.load(open("/tmp/ghd-clusters.json"))
target = open("/tmp/ghd-target.txt").read().strip()
demand_by_issue = {i["number"]: i["demand_score"] for i in filtered}
issue_titles = {i["number"]: i["title"] for i in filtered}
issue_reactions = {i["number"]: i["reactions_plus1"] for i in filtered}
enriched_themes = []
for theme in clusters.get("cluster_themes", []):
issue_nums = theme.get("issue_numbers", [])
total_demand = sum(demand_by_issue.get(n, 0) for n in issue_nums)
top_issues = sorted(issue_nums, key=lambda n: demand_by_issue.get(n, 0), reverse=True)[:3]
enriched_themes.append({
"theme_name": theme["theme_name"],
"category": theme["category"],
"issue_count": len(issue_nums),
"total_demand_score": round(total_demand, 1),
"top_issues": [
{"number": n, "title": issue_titles.get(n, ""), "reactions": issue_reactions.get(n, 0)}
for n in top_issues
]
})
enriched_themes.sort(key=lambda x: x["total_demand_score"], reverse=True)
json.dump(enriched_themes, open("/tmp/ghd-enriched-themes.json", "w"), indent=2)
print(f"Top 3 clusters for messaging brief (repo: {target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- total demand: {t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} reactions)")
PYEOF
Gere um briefing de mensagens GTM a partir dos 3 clusters principais impressos acima.
Regras:
- Cada ângulo de posicionamento deve citar o cluster específico de onde veio
- Cada gancho de contato deve citar um título de issue literal entre aspas
- Os títulos devem incluir um número ou uma dor específica nomeada -- sem declarações genéricas
- Sem travessões. Sem palavras proibidas: poderoso, robusto, perfeito, inovador, revolucionário, simplificar, alavancar, transformar
Escreva seu briefing em /tmp/ghd-brief.json com esta estrutura exata:
{
"repo": "owner/repo",
"total_issues_analyzed": 120,
"report_date": "2025-01-15T12:00:00Z",
"messaging_brief": {
"headline": "5 Missing Features Users Are Begging For",
"positioning_angles": [
{"angle": "Address the most upvoted feature gap", "source_cluster": "Missing export options", "rationale": ""}
],
"outreach_hooks": [
{"hook": "Your users keep asking: 'Support export to CSV'. We already built it.", "verbatim_issue": "Support export to CSV"}
]
},
"top_demand_clusters": []
}
Após escrever o arquivo, confirme com:
python3 -c "
import json
d = json.load(open('/tmp/ghd-brief.json'))
print(f'Brief written for {d[\"repo\"]}')
print(f'Headline: {d[\"messaging_brief\"][\"headline\"]}')
print(f'Positioning angles: {len(d[\"messaging_brief\"][\"positioning_angles\"])}')
print(f'Outreach hooks: {len(d[\"messaging_brief\"][\"outreach_hooks\"])}')
"
Passo 7: Saída Final
python3 << 'PYEOF'
import json
filtered = json.load(open("/tmp/ghd-filtered-issues.json"))
clusters = json.load(open("/tmp/ghd-clusters.json"))
enriched_themes = json.load(open("/tmp/ghd-enriched-themes.json"))
brief = json.load(open("/tmp/ghd-brief.json"))
target = open("/tmp/ghd-target.txt").read().strip()
# Ignored demand summary
ignored = [i for i in filtered if i["ignored_demand"]]
print("=" * 70)
print(f" DEMAND GAP REPORT: {target}")
print(f" Issues analyzed: {len(filtered)}")
print(f" Ignored demand issues: {len(ignored)}")
print("=" * 70)
print("\n--- TOP DEMAND CLUSTERS ---")
for t in enriched_themes[:6]:
print(f"\n {t['theme_name']} ({t['category']})")
print(f" Issues: {t['issue_count']}, Total demand: {t['total_demand_score']}")
for ti in t["top_issues"][:2]:
print(f" #{ti['number']}: {ti['title']}")
print("\n--- GO-TO-MARKET MESSAGING BRIEF ---")
print(f"\n Headline: {brief['messaging_brief']['headline']}")
print("\n Positioning Angles:")
for angle in brief['messaging_brief']['positioning_angles'][:3]:
print(f" - {angle['angle']} (from: {angle['source_cluster']})")
print("\n Outreach Hooks:")
for hook in brief['messaging_brief']['outreach_hooks'][:3]:
print(f" - {hook['hook']}")
print(f" (verbatim from issue: {hook['verbatim_issue']})")
print("\n--- IGNORED DEMAND ISSUES ---")
for i in ignored[:5]:
print(f" #{i['number']}: {i['title']} ({i['reactions_plus1']} reactions, {i['age_days']} days old)")
print("\n" + "=" * 70)
print(" Next step: Use these insights to build features,")
print(" improve messaging, and prioritize product roadmap.")
print("=" * 70)
PYEOF
Referência de Variáveis de Ambiente
| Variável | Obrigatória | Descrição |
|---|---|---|
GITHUB_TOKEN | Não (mas recomendado) | Token de acesso pessoal do GitHub para aumentar o limite de taxa de 60 req/h para 5000 req/h. Crie em github.com/settings/tokens (nenhuma permissão necessária para repositórios públicos). |