tools/feedback.py
Rama main · réplica de lectura del repositorio original.
"""Collect what people are saying about CLIque into one file worth reading.
Requests arrive as issues, as discussions, and — most easily missed — as
replies buried in a thread opened weeks ago. This pulls all three into
`docs/feedback-inbox.md`, marks what is new since the last run, and prints one
line saying so.
Official channels only, on purpose. What people say about CLIque elsewhere is
interesting, and it is not a queue. Mixing the two would mean triaging opinions
alongside requests, and the requests are what lose.
It is deliberately not a bug tracker. GitHub is the tracker; this is the
triage board in front of it, which is the thing that decides what gets built.
Authentication is `gh`'s, not ours — this shells out rather than holding a
token, so there is no credential here to leak or rotate.
Usage: python3 tools/feedback.py [--quiet]
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
REPO = "thejdubb02/clique"
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs" / "feedback-inbox.md"
#: Outside the repo on purpose: what has been seen is this machine's memory,
#: not a fact about the project, and it should not show up in a diff.
SEEN = Path.home() / ".clique" / "feedback-seen.json"
ZONE = ZoneInfo("America/Los_Angeles")
#: Long enough to be specific to this project, short enough that someone who
#: only wrote the name still gets caught.
MENTIONS = (REPO, "thejdubb02/clique")
def gh(*args: str) -> object:
"""`gh api ...`, or None if gh is missing, logged out, or unhappy."""
try:
out = subprocess.run(["gh", *args], capture_output=True, text=True,
timeout=30, check=True).stdout
except (OSError, subprocess.SubprocessError):
return None
try:
return json.loads(out or "null")
except json.JSONDecodeError:
return None
def issues() -> list[dict]:
rows = gh("api", f"repos/{REPO}/issues?state=open&per_page=50") or []
out = []
for row in rows:
if "pull_request" in row:
continue # a PR is work arriving, not work being asked for
out.append({
"key": f"issue:{row['number']}",
"kind": "issue",
"title": row.get("title", ""),
"who": (row.get("user") or {}).get("login", "?"),
"when": row.get("created_at", ""),
"url": row.get("html_url", ""),
"extra": ", ".join(label["name"] for label in row.get("labels", [])),
"comments": row.get("comments", 0),
})
return out
def issue_comments() -> list[dict]:
"""Every recent comment on every issue, in one call.
Here because the request in an issue is often not the one in its title.
Someone opens "the tabs are confusing", three people argue underneath it,
and the thing worth building is in the fourth reply. Watching only the
openings misses all of that.
"""
rows = gh("api", f"repos/{REPO}/issues/comments"
"?per_page=50&sort=created&direction=desc") or []
out = []
for row in rows:
body = " ".join((row.get("body") or "").split())
out.append({
"key": f"comment:{row.get('id')}",
"kind": "comment",
"title": body[:140] or "(empty)",
"who": (row.get("user") or {}).get("login", "?"),
"when": row.get("created_at", ""),
"url": row.get("html_url", ""),
"extra": "on #" + str(row.get("issue_url", "")).rsplit("/", 1)[-1],
"comments": 0,
})
return out
def discussions() -> list[dict]:
"""Open discussions. Only reachable over GraphQL, hence the query."""
query = """
query($owner:String!,$name:String!){
repository(owner:$owner,name:$name){
discussions(first:50, orderBy:{field:CREATED_AT, direction:DESC}){
nodes{ number title url createdAt category{name} author{login}
comments(last:20){ totalCount
nodes{ url createdAt bodyText author{login} } } }
}
}
}"""
owner, name = REPO.split("/")
data = gh("api", "graphql", "-f", f"query={query}",
"-F", f"owner={owner}", "-F", f"name={name}")
nodes = (((data or {}).get("data") or {}).get("repository") or {})
nodes = (nodes.get("discussions") or {}).get("nodes") or []
out = [{
"key": f"discussion:{n['number']}",
"kind": "discussion",
"title": n.get("title", ""),
"who": (n.get("author") or {}).get("login", "?"),
"when": n.get("createdAt", ""),
"url": n.get("url", ""),
"extra": (n.get("category") or {}).get("name", ""),
"comments": (n.get("comments") or {}).get("totalCount", 0),
} for n in nodes]
# Replies come back on the same round trip, so watching them costs nothing.
for n in nodes:
for c in (n.get("comments") or {}).get("nodes") or []:
body = " ".join((c.get("bodyText") or "").split())
out.append({
"key": f"dcomment:{c.get('url', '')}",
"kind": "comment",
"title": body[:140] or "(empty)",
"who": (c.get("author") or {}).get("login", "?"),
"when": c.get("createdAt", ""),
"url": c.get("url", ""),
"extra": f"on discussion #{n['number']}",
"comments": 0,
})
return out
def when(row: dict) -> str:
"""An ISO timestamp as a local date, because that is how anyone reads it."""
try:
return datetime.fromisoformat(
row["when"].replace("Z", "+00:00")).astimezone(ZONE).strftime("%Y-%m-%d")
except (ValueError, KeyError, AttributeError):
return "—"
def table(rows: list[dict]) -> str:
if not rows:
return "_Nothing._\n"
out = ["| | What | Who | When | |", "|---|---|---|---|---|"]
for row in sorted(rows, key=lambda r: r.get("when") or "", reverse=True):
title = row["title"].replace("|", "\\|").strip()[:110] or "(untitled)"
note = row["extra"] or ""
if row["comments"]:
note = (note + " " if note else "") + f"({row['comments']} replies)"
out.append(f"| {row['kind']} | [{title}]({row['url']}) | {row['who']} "
f"| {when(row)} | {note} |")
return "\n".join(out) + "\n"
def main() -> int:
quiet = "--quiet" in sys.argv
rows = issues() + issue_comments() + discussions()
try:
seen = set(json.loads(SEEN.read_text()))
except (OSError, ValueError):
seen = set()
fresh = [r for r in rows if r["key"] not in seen]
stamp = datetime.now(ZONE)
body = f"""# Feedback inbox
Generated by `tools/feedback.py` — **do not hand-edit**, it is overwritten.
Decisions live in [ideas-inbox.md](ideas-inbox.md) and
[../ROADMAP.md](../ROADMAP.md); this is only what arrived.
Last collected {stamp:%Y-%m-%d %H:%M %Z}.
## New since the last run
{table(fresh)}
## Open on GitHub
{table([r for r in rows if r['kind'] in ('issue', 'discussion')])}
## Replies
Someone answering their own thread three days later is where the actual
requirement usually turns up.
{table([r for r in rows if r['kind'] == 'comment'])}"""
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(body)
SEEN.parent.mkdir(parents=True, exist_ok=True)
SEEN.write_text(json.dumps(sorted(r["key"] for r in rows)))
if not quiet or fresh:
print(f"{len(rows)} tracked, {len(fresh)} new -> {OUT}")
for row in fresh:
print(f" {row['kind']:10} {row['title'][:70]} {row['url']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())