Señal de Demanda de Issues de GitHub
Toma un repositorio público de GitHub de un competidor. Obtén sus issues abiertos. Filtra el ruido localmente. Agrúpalos en 6 categorías de demanda. Puntúa según el compromiso real. Genera un informe de brechas de demanda clasificado y un resumen de mensajería GTM.
Regla crítica: Cada título de issue en el resultado debe ser textual de la respuesta de la API de GitHub. Cada nombre de tema de clúster debe derivarse de los títulos de issues reales en ese clúster. Si después del filtrado de ruido quedan menos de 10 issues, detente e informa al usuario: el repositorio es demasiado pequeño para una agrupación confiable. No se permite contenido de issues inventado en ningún lugar.
Errores Comunes
| El agente tenderá a... | Por qué está mal |
|---|---|
| Enviar los 200 issues crudos a la IA sin filtrar | Los issues de bots, PRs y el ruido sin compromiso inflan los conteos de clústeres y desperdician contexto. Filtra localmente primero. |
| Usar el conteo de comentarios como la señal principal de demanda | Los comentarios incluyen respuestas de mantenedores, discusiones fuera de tema y spam. Las reacciones +1 son la señal de comprador más limpia. |
| Parafrasear los títulos de issues al resumir clústeres | Parafrasear pierde el lenguaje exacto del comprador, que es el punto central. Usa los títulos de issues textuales. |
| Continuar más allá del Paso 4 si quedan menos de 10 issues después del filtrado | Menos de 10 issues significa que el repositorio es demasiado pequeño o se dio una URL incorrecta. Agrupar datos dispersos produce categorías sin sentido. |
| Incluir pull requests en el análisis | El endpoint de Issues de GitHub también devuelve PRs. Filtra verificando que la clave pull_request esté ausente en el objeto del issue. |
| Marcar un issue como demanda ignorada sin verificar los 3 criterios | Los tres deben ser ciertos: reacciones >= 10, antigüedad >= 180 días, sin etiqueta de planificado/en progreso/hoja de ruta. La falta de un criterio descalifica el issue. |
Paso 1: Verificación de Configuración
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-no configurado, se aplica límite de tasa no autenticado (60 solicitudes/hora)}"
Si GITHUB_TOKEN no está configurado: Continúa. Informa al usuario: "GITHUB_TOKEN no está configurado. El límite de tasa no autenticado es de 60 solicitudes/hora, suficiente para 2 descargas antes de alcanzar el límite. Para uso repetido, añade un token en github.com/settings/tokens (no se necesitan permisos para repositorios públicos)."
Paso 2: Recopilar Entrada
Necesitas:
- URL del repositorio de GitHub (ej. https://github.com/propietario/repositorio) o slug propietario/repositorio (ej. facebook/react)
Analiza propietario y repositorio de la entrada:
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normalizar a propietario/repositorio
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERROR: No se pudo analizar la URL de GitHub. Formato esperado: https://github.com/propietario/repositorio")
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: La entrada debe ser una URL de GitHub o un slug propietario/repositorio (ej. vercel/next.js)")
sys.exit(1)
print(f"Propietario: {owner}")
print(f"Repositorio: {repo}")
with open("/tmp/ghd-target.txt", "w") as f:
f.write(f"{owner}/{repo}")
PYEOF
Si el análisis falla: Detente. Pregunta: "Proporciona el repositorio de GitHub como una URL (https://github.com/propietario/repositorio) o un slug propietario/repositorio (ej. vercel/next.js)."
Paso 3: Obtener Issues de la API REST de GitHub
Obtén hasta 200 issues (2 páginas de 100). Verifica el límite de tasa después de la primera descarga.
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 límite de tasa después de la primera 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 "desconocido"
print(f"ERROR: Límite de tasa de GitHub agotado. Se restablece a las {reset_str}.")
print("Añade GITHUB_TOKEN a tu archivo .env para obtener 5000 solicitudes/hora. Consulta github.com/settings/tokens (no se necesitan permisos).")
sys.exit(1)
print(f"Límite de tasa restante: {remaining}")
# Verificar 404/403
status = resp.status
if status == 404:
print(f"ERROR: Repositorio '{owner_repo}' no encontrado. Verifica la URL o el slug.")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Página {page}: vacía, deteniendo.")
break
all_issues.extend(page_data)
print(f"Página {page}: {len(page_data)} issues obtenidos")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERROR: Repositorio '{owner_repo}' no encontrado (404). Verifica la URL o el slug.")
elif e.code == 403:
print(f"ERROR: Acceso denegado (403). El repositorio puede ser privado o se alcanzó el límite de tasa.")
else:
print(f"ERROR: La API de GitHub devolvió HTTP {e.code}")
sys.exit(1)
except Exception as e:
print(f"ERROR: Falló la descarga de la página {page}: {e}")
sys.exit(1)
print(f"Total de issues crudos obtenidos: {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
Si GitHub devuelve 404: Detente. Informa al usuario: "Repositorio no encontrado. Verifica la URL o el slug e inténtalo de nuevo. Los repositorios privados no son accesibles sin autenticación y permiso explícito de repositorio."
Si GitHub devuelve 403 con el encabezado de límite de tasa: Detente. Muestra la hora de restablecimiento e indica al usuario que añada GITHUB_TOKEN.
Paso 4: Preprocesar Localmente -- Filtrar, Puntuar, Detectar Demanda Ignorada
Sin llamada a la API. Python puro. Ejecuta antes de enviar cualquier cosa a la 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:
# Omitir pull requests (el endpoint de Issues de GitHub también devuelve 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")
# Omitir issues creados por bots
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# Omitir títulos con patrón de bot
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# Omitir 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
# Omitir issues sin señal
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# Calcular puntuación 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 puntuación de demanda descendente
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"Issues crudos: {len(raw)}")
print(f"Ruido filtrado: {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"Issues para análisis: {len(filtered)}")
if len(filtered) < 10:
print(f"ERROR: Solo quedan {len(filtered)} issues después del filtrado. Este repositorio tiene muy pocos issues con compromiso para una agrupación confiable.")
print("Prueba con un repositorio más grande o con más participación de la comunidad.")
import sys; sys.exit(1)
# Resumen de demanda ignorada
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"Issues de demanda ignorada: {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("Preprocesamiento completado.")
PYEOF
Si quedan menos de 10 issues después del filtrado: Detente. Informa al usuario exactamente cuántos issues se encontraron y filtraron, y por qué el repositorio es demasiado pequeño para una agrupación confiable de demanda.
Paso 5: Agrupar Issues
Imprime los issues filtrados para su análisis:
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"Repositorio: {target}")
print(f"Issues a agrupar: {len(issue_list)}")
print()
for i in issue_list:
labels_str = f" [{', '.join(i['labels'][:3])}]" if i['labels'] else ""
print(f"#{i['number']} [puntuación:{round(i['demand_score'],1)} reacciones:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
Clasifica cada issue impreso anteriormente en una de estas 6 categorías:
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
Reglas:
- Clasifica cada issue exactamente en una categoría
- Extrae una declaración de dolor de 1 oración usando el lenguaje exacto del usuario del título -- no parafrasees
- Identifica 5-8 temas de clúster: frases cortas (3-6 palabras) que capturen los patrones de queja dominantes en todos los issues
- Sin guiones largos. Sin lenguaje de marketing.
Escribe tu análisis en /tmp/ghd-clusters.json con esta estructura exacta:
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "Los usuarios necesitan X que aún no existe"}
],
"cluster_themes": [
{"theme_name": "Faltan opciones de exportación", "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}
}
Después de escribir el archivo, confirma con:
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'Clasificados: {len(d[\"classified_issues\"])} issues, {len(d[\"cluster_themes\"])} temas')
print('Categorías:', d['category_counts'])
"
Paso 6: Resumen de Mensajería
Calcula la puntuación total de demanda por clúster e imprime los 3 principales:
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"3 principales clústeres para el resumen de mensajería (repositorio: {target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- demanda total: {t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} reacciones)")
PYEOF
Genera un resumen de mensajería GTM a partir de los 3 principales clústeres impresos anteriormente.
Reglas:
- Cada ángulo de posicionamiento debe citar el clúster específico del que proviene
- Cada gancho de alcance debe citar un título de issue textual entre comillas
- Los titulares deben incluir un número o un dolor específico nombrado -- sin declaraciones genéricas
- Sin guiones largos. Sin palabras prohibidas: potente, robusto, perfecto, innovador, revolucionario, optimizar, aprovechar, transformar
Escribe tu resumen en /tmp/ghd-brief.json con esta estructura exacta:
...
[truncado para traducción]