Signal de Demande Issues GitHub
Analysez le dépôt public d'un concurrent. Récupérez ses issues ouvertes. Filtrez le bruit localement. Regroupez en 6 catégories de demande. Notez selon l'engagement réel. Produisez un rapport classé des opportunités de marché et un bref marketing.
Règle critique : Chaque titre d'issue dans la sortie doit être textuellement issu de la réponse de l'API GitHub. Chaque nom de thème de cluster doit être dérivé des titres d'issues réels de ce cluster. Si moins de 10 issues restent après le filtrage du bruit, arrêtez et dites à l'utilisateur – le dépôt est trop petit pour un regroupement fiable. Pas de contenu d'issue inventé nulle part.
Erreurs Courantes
| L'agent voudra... | Pourquoi c'est faux |
|---|---|
| Envoyer les 200 issues brutes à l'IA sans filtrage | Les issues de robots, les PRs et le bruit sans engagement gonflent les comptes de clusters et gaspillent le contexte. Filtrez localement d'abord. |
| Utiliser le nombre de commentaires comme signal de demande principal | Les commentaires incluent les réponses des mainteneurs, les discussions hors sujet et le spam. Les réactions["+1"] sont le signal acheteur le plus propre. |
| Paraphraser les titres d'issues lors du résumé des clusters | La paraphrase perd le langage exact de l'acheteur, qui est le point central. Utilisez les titres d'issues textuels. |
| Continuer au-delà de l'étape 4 si moins de 10 issues restent après le filtrage | Moins de 10 issues signifie que le dépôt est trop petit ou que la mauvaise URL a été donnée. Le regroupement sur des données éparses produit des catégories insignifiantes. |
| Inclure les pull requests dans l'analyse | L'endpoint Issues GitHub renvoie aussi les PRs. Filtrez en vérifiant que la clé pull_request est absente sur l'objet issue. |
| Marquer une issue comme demande ignorée sans vérifier les 3 critères | Les trois doivent être vrais : réactions >= 10, âge >= 180 jours, pas d'étiquette planifié/en cours/feuille de route. Un seul critère manquant disqualifie l'issue. |
Étape 1 : Vérification de Configuration
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-non défini, limite de débit non authentifié s'applique (60 req/h)}"
Si GITHUB_TOKEN n'est pas défini : Continuez. Dites à l'utilisateur : "GITHUB_TOKEN n'est pas défini. La limite de débit non authentifié est de 60 requêtes/heure – suffisant pour 2 récupérations avant d'atteindre la limite. Pour une utilisation répétée, ajoutez un jeton sur github.com/settings/tokens (aucun scope nécessaire pour les dépôts publics)."
Étape 2 : Recueillir l'Entrée
Vous avez besoin :
- URL du dépôt GitHub (ex. https://github.com/proprietaire/depot) ou slug proprietaire/depot (ex. facebook/react)
Analysez propriétaire et dépôt à partir de l'entrée :
python3 << 'PYEOF'
import re, sys, os
raw = "REPO_INPUT_HERE"
# Normaliser en proprietaire/depot
if raw.startswith("http"):
m = re.search(r"github\.com/([^/]+)/([^/?\s]+)", raw)
if not m:
print("ERREUR : Impossible d'analyser l'URL GitHub. Format attendu : https://github.com/proprietaire/depot")
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("ERREUR : L'entrée doit être une URL GitHub ou un slug proprietaire/depot (ex. vercel/next.js)")
sys.exit(1)
print(f"Propriétaire : {owner}")
print(f"Dépôt : {repo}")
with open("/tmp/ghd-target.txt", "w") as f:
f.write(f"{owner}/{repo}")
PYEOF
Si l'analyse échoue : Arrêtez. Demandez : "Veuillez fournir le dépôt GitHub sous forme d'URL (https://github.com/proprietaire/depot) ou d'un slug proprietaire/depot (ex. vercel/next.js)."
Étape 3 : Récupérer les Issues de l'API REST GitHub
Récupérez jusqu'à 200 issues (2 pages de 100). Vérifiez la limite de débit après la première récupération.
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:
# Vérifier la limite de débit après la première page
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 "inconnu"
print(f"ERREUR : Limite de débit GitHub épuisée. Réinitialisation à {reset_str}.")
print("Ajoutez GITHUB_TOKEN à votre fichier .env pour obtenir 5000 req/h. Voir github.com/settings/tokens (aucun scope nécessaire).")
sys.exit(1)
print(f"Limite de débit restante : {remaining}")
# Vérifier 404/403
status = resp.status
if status == 404:
print(f"ERREUR : Dépôt '{owner_repo}' introuvable. Vérifiez l'URL ou le slug.")
sys.exit(1)
page_data = json.loads(resp.read())
if not page_data:
print(f"Page {page} : vide, arrêt.")
break
all_issues.extend(page_data)
print(f"Page {page} : {len(page_data)} issues récupérées")
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"ERREUR : Dépôt '{owner_repo}' introuvable (404). Vérifiez l'URL ou le slug.")
elif e.code == 403:
print(f"ERREUR : Accès refusé (403). Le dépôt peut être privé ou la limite de débit atteinte.")
else:
print(f"ERREUR : L'API GitHub a renvoyé HTTP {e.code}")
sys.exit(1)
except Exception as e:
print(f"ERREUR : Échec de la récupération de la page {page} : {e}")
sys.exit(1)
print(f"Total des issues brutes récupérées : {len(all_issues)}")
json.dump(all_issues, open("/tmp/ghd-raw-issues.json", "w"), indent=2)
PYEOF
Si GitHub renvoie 404 : Arrêtez. Dites à l'utilisateur : "Dépôt introuvable. Vérifiez l'URL ou le slug et réessayez. Les dépôts privés ne sont pas accessibles sans authentification et scope de dépôt explicite."
Si GitHub renvoie 403 avec l'en-tête de limite de débit : Arrêtez. Affichez l'heure de réinitialisation et dites à l'utilisateur d'ajouter GITHUB_TOKEN.
Étape 4 : Prétraitement Local – Filtrer, Noter, Détecter la Demande Ignorée
Aucun appel API. Python pur. Exécutez avant toute transmission à l'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:
# Ignorer les pull requests (l'endpoint Issues GitHub renvoie aussi les 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")
# Ignorer les issues créées par des robots
if user_type == "Bot":
noise_count += 1
noise_reasons["bot_author"] = noise_reasons.get("bot_author", 0) + 1
continue
# Ignorer les titres de motifs de robots
if noise_patterns.match(title):
noise_count += 1
noise_reasons["bot_title"] = noise_reasons.get("bot_title", 0) + 1
continue
# Ignorer les titres d'issues ressemblant à des PR
if pr_title_patterns.match(title):
noise_count += 1
noise_reasons["pr_as_issue"] = noise_reasons.get("pr_as_issue", 0) + 1
continue
# Ignorer les issues sans signal
if reactions == 0 and comments == 0:
noise_count += 1
noise_reasons["zero_signal"] = noise_reasons.get("zero_signal", 0) + 1
continue
# Calculer le score de demande
demand_score = (reactions * 2) + (comments * 0.5)
# Détecter la demande ignorée
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]
})
# Trier par score de demande décroissant
filtered.sort(key=lambda x: x["demand_score"], reverse=True)
print(f"Issues brutes : {len(raw)}")
print(f"Bruit filtré : {noise_count} ({', '.join(f'{k}: {v}' for k, v in noise_reasons.items())})")
print(f"Issues pour analyse : {len(filtered)}")
if len(filtered) < 10:
print(f"ERREUR : Seulement {len(filtered)} issues restent après filtrage. Ce dépôt a trop peu d'issues engagées pour un regroupement fiable.")
print("Essayez un dépôt plus grand ou un dépôt avec plus d'engagement communautaire.")
import sys; sys.exit(1)
# Résumé de la demande ignorée
ignored = [i for i in filtered if i["ignored_demand"]]
print(f"Issues de demande ignorée : {len(ignored)}")
json.dump(filtered, open("/tmp/ghd-filtered-issues.json", "w"), indent=2)
print("Prétraitement terminé.")
PYEOF
Si moins de 10 issues restent après le filtrage : Arrêtez. Dites à l'utilisateur exactement combien d'issues ont été trouvées et filtrées, et pourquoi le dépôt est trop petit pour un regroupement de demande fiable.
Étape 5 : Regrouper les Issues
Affichez les issues filtrées pour analyse :
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"Dépôt : {target}")
print(f"Issues à regrouper : {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)} réactions:{i['reactions_plus1']}] {i['title']}{labels_str}")
PYEOF
Classez chaque issue affichée ci-dessus dans l'une de ces 6 catégories :
feature_gap, bug_pattern, ux_complaint, performance, integration_missing, docs_missing
Règles :
- Classez chaque issue dans exactement une catégorie
- Extrayez une déclaration de douleur en une phrase en utilisant le langage exact de l'utilisateur dans le titre – ne paraphrasez pas
- Identifiez 5 à 8 thèmes de cluster : des phrases courtes (3 à 6 mots) capturant les motifs de plainte dominants dans toutes les issues
- Pas de tirets em. Pas de langage marketing.
Écrivez votre analyse dans /tmp/ghd-clusters.json avec cette structure exacte :
{
"classified_issues": [
{"number": 123, "category": "feature_gap", "pain_statement": "Les utilisateurs ont besoin de X qui n'existe pas encore"}
],
"cluster_themes": [
{"theme_name": "Options d'export manquantes", "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}
}
Après avoir écrit le fichier, confirmez avec :
python3 -c "
import json
d = json.load(open('/tmp/ghd-clusters.json'))
print(f'Classées : {len(d[\"classified_issues\"])} issues, {len(d[\"cluster_themes\"])} thèmes')
print('Catégories :', d['category_counts'])
"
Étape 6 : Brief Marketing
Calculez le score total de demande par cluster et affichez le top 3 :
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 pour brief marketing (dépôt : {target}):")
for t in enriched_themes[:3]:
print(f"\n {t['theme_name']} ({t['category']}) -- demande totale : {t['total_demand_score']}")
for ti in t["top_issues"]:
print(f" #{ti['number']}: \"{ti['title']}\" ({ti['reactions']} réactions)")
PYEOF
Générez un brief marketing (GTM) à partir des 3 premiers clusters affichés ci-dessus.
Règles :
- Chaque angle de positionnement doit citer le cluster spécifique dont il provient
- Chaque accroche de prospection doit citer un titre d'issue textuel entre guillemets
- Les titres doivent inclure un chiffre ou une douleur nommée spécifique – pas de déclarations génériques
- Pas de tirets em. Pas de mots interdits : puissant, robuste, sans couture, innovant, révolutionnaire, rationaliser, exploiter, transformer
Écrivez votre brief dans /tmp/ghd-brief.json avec cette structure exacte :
…
[truncated for translation]