diff --git a/REFERENCE.md b/REFERENCE.md index a77ef1c..328d246 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -44,8 +44,8 @@ requested review too; bot messages do not create a human response obligation. Response timing uses the earliest external message after the last maintainer message, including PR review comments and submitted reviews. A reply is not -proof of resolution. Missing timestamps/order mean unknown. Accepted answers -and broadcast categories preserve the existing hub settlement semantics. +proof of resolution. Missing timestamps/order mean unknown. Accepted answers settle earlier activity; later external comments require follow-up review. +Broadcast categories retain their existing non-response semantics. Discussion activity uses fixed read-only GraphQL queries for the complete top-level comment connection and each comment's reply connection. Every connection follows `pageInfo` cursors independently up to `max_pages`; a @@ -60,9 +60,8 @@ read is distinct from a successful empty read. REST PR review comments include inline replies; submitted reviews and issue comments are separately paginated. This measures response activity, not review-thread resolution or delivery. An accepted proposal settles the hub response obligation only; implementation -and release evidence belong to the separate follow-through projection. Accepted-answer -and broadcast policy can settle response obligation even while a coverage gap -remains visible. +and release evidence belong to the separate follow-through projection. Broadcast policy can settle response obligation while a coverage gap remains visible. +Settled non-broadcast threads with incomplete follow-up evidence stay unknown. The CLI returns 2 for invalid configuration, input or render failure. Source read failures appear in a valid degraded snapshot so the dashboard can expose @@ -119,7 +118,8 @@ agent session is created. The workflow is ordinary repository automation. (publicly verified URL/repo/kind triples) and bounded `gaps`. No source bodies or manual task assignments are stored. Old snapshots without the extension remain valid. Conversations may carry boolean `closed`; closed Discussions -are collected separately, remain settled, and do not count as open in Brain. +are collected separately and do not count as open in Brain; new external activity +after settlement can require follow-up review independently of delivery status. A configured maintainer records explicit links on their own source body or comment/reply, one unquoted standalone line per link: @@ -204,3 +204,31 @@ URL mentions do not qualify. This means a plan exists, not that it is approved or implemented. This hint is render-time only: snapshot v1, delivery states and Brain's cockpit feed remain unchanged. Rendering a snapshot without `--mind` keeps source/delivery badges and makes no plan claim. + +## Post-settlement activity (optional snapshot v1 extension) + +A settled non-broadcast Discussion or closed issue carries `follow_up` with +nullable boolean `review_needed`, nullable timestamp `since`, and nullable `url`. +True means an external human comment after the latest closure/answer boundary +and after the last maintainer response needs review. It does not assert semantic +actionability: even thanks can need contextual inspection. The timestamp is the +oldest pending comment, and the URL links to that comment when an identity is +available, otherwise the thread. False/null carry no timestamp or URL. Response +state mirrors this observation; unknown activity or settlement time is partial, +never checked clear. Discussion closure time comes from a fixed read-only +GraphQL query because REST does not supply it. No permissions or bodies are +published. Readers accept old snapshots without this optional field. + +Closed issues and Discussions are scanned by most recently updated first, under +`max_pages`; truncated history is an explicit coverage gap. Closed PRs are not +read. Closed maintainer-authored issues are checked for external comments too; +quiet closed issues are omitted. New activity can bring an old thread into the +bounded window. This is not an unlimited historical audit. + +Brain Community reviews the source comments and nested replies for requests, +acknowledgements or ambiguity and recommends the next step. A contributor's +ability to reopen is never a prerequisite. Where a reopen is appropriate, verify +the acting account's permission or refer it to a maintainer; never infer the +commenter's permissions from the collector token. Reopening, unlocking and +clearing an answer are separate, explicitly authorized actions. Ears performs +none of them. Stale/cached evidence must be refreshed before acting. diff --git a/ears/board.py b/ears/board.py index 5c57e05..0943662 100644 --- a/ears/board.py +++ b/ears/board.py @@ -9,7 +9,7 @@ from pathlib import Path from .collect import line, now, utc, validate -from .presentation import CHECKIN, CSS, copy_button, pill, progress, waiting_label +from .presentation import CHECKIN, CSS, copy_button, pill, progress, waiting_label, triage_prompt def load_module(path, name): @@ -56,7 +56,7 @@ def render(snapshot, config, brain, rendered_at=None, plans=()): headline = "STALE — " + headline items = [] for row in attention + [r for r in unknown if r not in attention]: - prompt = theme.portable_prompt(f"/community triage {row['url']}") + prompt = theme.portable_prompt(triage_prompt(row)) items.append({"id": row["id"], "severity": "yellow", "text": line(row["title"]), "url": row["url"], "prompt": prompt, "state": "unknown" if row in unknown else "action_required", @@ -133,8 +133,16 @@ def section(title, key, selected, empty): label, tone = progress(row, d, stale, plans) timing = waiting_label(row, snapshot['generated']) kind = {'issue': 'Issue', 'pr': 'PR', 'discussion': 'Discussion'}[row['kind']] - prompt = theme.portable_prompt(f"/community triage {row['url']}") + prompt = theme.portable_prompt(triage_prompt(row)) details = '

Open ' + kind.lower() + ' ↗

' + if 'follow_up' in row: + settled = 'Closed' if row.get('closed') else 'Open' + settled += ' · Answered' if row['answered'] else '' + details += '

Thread status: ' + settled + '

' + candidate = row['follow_up'].get('url') + if candidate: + details += '

Read follow-up ↗

' + details += '

Activity needs review; this is not a decision to reopen. Contributors may lack reopening permission.

' if row.get('waiting_since'): details += '

Waiting since

' if stale: diff --git a/ears/collect.py b/ears/collect.py index 0786f8e..c31b69b 100644 --- a/ears/collect.py +++ b/ears/collect.py @@ -12,7 +12,7 @@ DISCUSSION_COMMENTS = """query($owner:String!, $name:String!, $number:Int!, $after:String) { repository(owner:$owner, name:$name) { discussion(number:$number) { comments(first:100, after:$after) { - nodes { id body createdAt deletedAt author { login __typename } } + nodes { id databaseId body createdAt deletedAt author { login __typename } } pageInfo { hasNextPage endCursor } } } } @@ -20,12 +20,18 @@ DISCUSSION_REPLIES = """query($id:ID!, $after:String) { node(id:$id) { ... on DiscussionComment { replies(first:100, after:$after) { - nodes { id body createdAt deletedAt author { login __typename } } + nodes { id databaseId body createdAt deletedAt author { login __typename } } pageInfo { hasNextPage endCursor } } } } }""" +DISCUSSION_STATE = """query($owner:String!, $name:String!, $number:Int!) { + repository(owner:$owner, name:$name) { discussion(number:$number) { + closed closedAt answerChosenAt + } } +}""" + def utc(value): if not isinstance(value, str): @@ -87,6 +93,23 @@ def get(self, endpoint): except ValueError: raise ReadError("invalid JSON response") from None + def discussion_state(self, repo, number): + owner, name = repository(repo).split("/") + payload = json.dumps({"query": DISCUSSION_STATE, + "variables": {"owner": owner, "name": name, "number": number}}) + try: + r = subprocess.run([self.binary, "api", "graphql", "--input", "-"], + input=payload, capture_output=True, text=True, timeout=60) + result = json.loads(r.stdout) + if r.returncode or not isinstance(result, dict) or result.get("errors"): + raise ReadError("Discussion settlement read failed") + detail = result["data"]["repository"]["discussion"] + if not isinstance(detail, dict) or type(detail.get("closed")) is not bool: + raise ReadError("Discussion settlement unavailable") + return detail + except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, TypeError): + raise ReadError("Discussion settlement unavailable") from None + def discussion_page(self, repo, number, after=None, comment_id=None): """Only these fixed queries are allowed; source text is never executable.""" owner, name = repository(repo).split("/") @@ -156,7 +179,7 @@ def discussion_activity(api, repo, number, cap): if why: gaps.append(why) normalized = [{"created_at": x.get("createdAt"), "deleted_at": x.get("deletedAt"), - "body": x.get("body"), + "body": x.get("body"), "id": x.get("databaseId"), "user": {"login": x["author"].get("login"), "type": x["author"].get("__typename")} if isinstance(x.get("author"), dict) else None} for x in activity] @@ -227,6 +250,60 @@ def waiting(events, selves): return bool(pending), pending +def follow_up(raw, comments, kind, selves, complete, url): + """Post-settlement human activity, never a semantic actionability verdict.""" + unknown = {"review_needed": None, "since": None, "url": None} + markers = [] + if raw.get("state") == "closed": + markers.append(raw.get("closed_at")) + if kind == "discussion" and raw.get("answer_chosen_at") is not None: + markers.append(raw["answer_chosen_at"]) + try: + boundary = max(utc(t) for t in markers) + events, gaps = activity_events(comments) + if not complete or gaps: + return unknown + # Parse every event before filtering, so missing times cannot hide activity. + later = [(t, who) for t, who in events if utc(t) > boundary] + needed, since = waiting(later, selves) + if needed is None: + return unknown + link = None + if needed: + candidate = next(c for c in comments if human(c.get("user")) + and c["user"]["login"].casefold() not in selves + and utc(c.get("created_at")) == utc(since)) + identity = candidate.get("id") + anchor = "discussioncomment" if kind == "discussion" else "issuecomment" + link = url + (f"#{anchor}-{identity}" if type(identity) is int and identity > 0 else "") + return {"review_needed": needed, "since": since, "url": link} + except (ValueError, TypeError, StopIteration): + return unknown + + +def validate_follow_up(row): + if "follow_up" not in row: + return + f = row["follow_up"] + if not isinstance(f, dict) or set(f) != {"review_needed", "since", "url"}: + raise ValueError("invalid follow-up observation") + if f["review_needed"] is not None and type(f["review_needed"]) is not bool: + raise ValueError("invalid follow-up state") + if row["kind"] not in {"issue", "discussion"} or not (row.get("closed") or row["answered"]): + raise ValueError("follow-up lacks settled thread") + if f["review_needed"] is True: + utc(f["since"]) + anchor = "discussioncomment" if row["kind"] == "discussion" else "issuecomment" + if not isinstance(f["url"], str) or not re.fullmatch(re.escape(row["url"]) + rf"(#{anchor}-[1-9][0-9]*)?", f["url"]): + raise ValueError("invalid follow-up source") + if row["coverage"] != "complete" or row["awaiting_response"] is not True or row["waiting_since"] != f["since"]: + raise ValueError("inconsistent follow-up evidence") + elif f["since"] is not None or f["url"] is not None: + raise ValueError("inactive follow-up carries candidate") + if row["awaiting_response"] is not f["review_needed"]: + raise ValueError("inconsistent follow-up response") + + def conversation(api, repo, raw, kind, config, deliveries=None, pending=None): number = raw.get("number") if not isinstance(number, int) or isinstance(number, bool) or number < 1: @@ -265,10 +342,32 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None): gaps.append("activity timestamps unavailable or ordering ambiguous") category = (raw.get("category") or {}).get("name") answered = raw.get("answer_chosen_at") is not None - # Preserve the established hub policy; delivery follow-up is separate. - if kind == "discussion" and (answered or category in BROADCAST): - awaiting, since = False, None - if kind == "discussion" and (raw.get("state") == "closed" or raw.get("locked")): + observation = None + settled = raw.get("state") == "closed" or (kind == "discussion" and answered) + if kind != "pr" and settled and category not in BROADCAST: + # REST Discussions omit closed_at. Missing/unreadable GraphQL evidence + # must never turn a closed thread into a checked-clear observation. + if kind == "discussion" and raw.get("state") == "closed" and not raw.get("closed_at"): + try: + detail = api.discussion_state(repo, number) + graph_answer = detail.get("answerChosenAt") + rest_answer = raw.get("answer_chosen_at") + same_answer = ((graph_answer is None and rest_answer is None) or + (graph_answer is not None and rest_answer is not None and + utc(graph_answer) == utc(rest_answer))) + if not detail["closed"] or not same_answer: + raise ReadError("Discussion settlement changed during collection") + raw = dict(raw, closed_at=detail.get("closedAt")) + except (AttributeError, KeyError, TypeError, ValueError, ReadError): + complete = False + gaps.append("Discussion settlement unavailable or changed during collection") + observation = follow_up(raw, comments, kind, selves, complete, + f"https://github.com/{repo}/{path}/{number}") + awaiting, since = observation["review_needed"], observation["since"] + if awaiting is None: + complete = False + gaps.append("post-settlement activity or settlement time unknown") + elif kind == "discussion" and (category in BROADCAST or raw.get("locked")): awaiting, since = False, None if kind == "discussion" and deliveries is not None: from .followthrough import derive @@ -287,9 +386,22 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None): "coverage": "complete" if complete and awaiting is not None else "partial", "gaps": gaps, "cached": False, "feedback_report": "" in (raw.get("body") or ""), + **({"follow_up": observation} if observation is not None else {}), } +def quiet_closed_issue(entry): + """Avoid rereading historical comment threads with no post-closure update.""" + if entry.get("state") != "closed" or "pull_request" in entry: + return False + if type(entry.get("comments")) is int and entry["comments"] == 0: + return True + try: + return utc(entry.get("updated_at")) == utc(entry.get("closed_at")) + except (ValueError, TypeError): + return False + + def collect(api, repo_homes, config, pending=None): hub = repository(config["hub"]) selves = {x.casefold() for x in config["self_logins"]} @@ -318,14 +430,17 @@ def collect(api, repo_homes, config, pending=None): entries, complete, why = pages(api, f"repos/{repo}/{endpoint}?state=open", config["max_pages"]) if why: receipt["gaps"].append(f"{endpoint}: {why}") - if kind == "discussion": - closed, _, gap = pages(api, f"repos/{repo}/discussions?state=closed", config["max_pages"]) - entries.extend(closed) - entries = list({e.get("number"): e for e in entries}.values()) - if gap: - receipt["gaps"].append(f"closed discussions: {gap}") + # Recent activity, not creation time, determines the bounded sweep. + closed_endpoint = f"repos/{repo}/{endpoint}?state=closed&sort=updated&direction=desc" + closed, _, gap = pages(api, closed_endpoint, config["max_pages"]) + entries.extend(e for e in closed if kind == "discussion" or "pull_request" not in e) + entries = list({e.get("number"): e for e in entries}.values()) + if gap: + receipt["gaps"].append(f"closed {endpoint}: {gap}") for entry in entries: - if kind != "discussion" and (entry.get("state", "open") != "open" or entry.get("locked")): + if kind == "issue" and quiet_closed_issue(entry): + continue + if kind != "discussion" and "pull_request" in entry and (entry.get("state", "open") != "open" or entry.get("locked")): continue item_kind = "pr" if "pull_request" in entry else kind is_human = human(entry.get("user")) @@ -335,7 +450,7 @@ def collect(api, repo_homes, config, pending=None): if known_bot and item_kind != "pr": continue external = is_human and entry["user"]["login"].casefold() not in selves - if item_kind == "issue" and is_human and not external: + if item_kind == "issue" and is_human and not external and entry.get("state") != "closed": continue try: row = conversation(api, repo, entry, item_kind, config, deliveries, pending) @@ -349,6 +464,9 @@ def collect(api, repo_homes, config, pending=None): if item_kind == "pr" and not external and not row["review_requested"] \ and row["coverage"] == "complete": continue + # Quiet closed issues add no queue value; keep unknowns visible. + if item_kind == "issue" and row.get("closed") and row["awaiting_response"] is False: + continue source_rows.append(row) receipt["status"] = "partial" if receipt["gaps"] else "complete" rows.extend(source_rows) @@ -388,7 +506,7 @@ def validate(snapshot): for row in rows: if not isinstance(row, dict): raise ValueError("invalid conversation") - if set(row) - {"closed"} != {"id", "repo", "number", "kind", "url", "title", "author", "category", "answered", + if set(row) - {"closed", "follow_up"} != {"id", "repo", "number", "kind", "url", "title", "author", "category", "answered", "awaiting_response", "waiting_since", "review_requested", "coverage", "gaps", "cached", "feedback_report"}: raise ValueError("unknown conversation fields; raw content must not be published") if "closed" in row and type(row["closed"]) is not bool: @@ -405,6 +523,7 @@ def validate(snapshot): if row.get("id") != identity or row.get("url") != f"https://github.com/{identity}" or identity in ids: raise ValueError("invalid or duplicate conversation identity/link") ids.add(identity) + validate_follow_up(row) if row.get("awaiting_response") is not None and type(row["awaiting_response"]) is not bool: raise ValueError("invalid awaiting-response state") if row.get("waiting_since"): diff --git a/ears/presentation.py b/ears/presentation.py index 9f335ff..e0e4a20 100644 --- a/ears/presentation.py +++ b/ears/presentation.py @@ -17,6 +17,11 @@ "response states, recent activity and contributor updates owed. Check linked plans, " "issues and PRs for progress. Give me a concise priority list explaining who is waiting, " "what they need, what has changed and the next useful action.\n\n" + "Review new comments on answered or closed threads, including nested replies. Distinguish " + "acknowledgements from actionable requests or uncertain follow-ups. Contributors may lack " + "permission to reopen; a comment is enough to request attention. Check the acting account's " + "permission before recommending who should reopen, and route to a maintainer if unavailable " + "or unknown. Reopening, unlocking and clearing an answer are separate actions.\n\n" "When I name a thread, contributor, question or idea, make that the main focus. Help me " "understand the conversation, investigate the reported problem, identify missing " "information, discuss possible responses or prepare a contributor handoff. Bring in " @@ -102,6 +107,8 @@ def progress(row, delivery, stale, plans): def waiting_label(row, observed): + if row.get('follow_up', {}).get('review_needed') is True: + return 'Follow-up needs review' since = row.get('waiting_since') if since: duration = utc(observed) - utc(since) @@ -120,6 +127,17 @@ def waiting_label(row, observed): return 'Watching' +def triage_prompt(row): + prompt = f"/community triage {row['url']}" + if "follow_up" in row: + prompt += (" — review post-settlement comments and nested replies for actionable requests, " + "acknowledgements or uncertainty. Contributors may lack permission to reopen; " + "check the acting account's permission and route to a maintainer if unknown or " + "unavailable. Recommend a response, reopening or linked new task; do not post " + "or change thread state without explicit authorization.") + return prompt + + CSS = ''' [hidden]{display:none!important} body{max-width:1240px;padding:0 24px 48px}main{min-width:0} diff --git a/tests/board_browser.py b/tests/board_browser.py index 8243518..41e0e38 100644 --- a/tests/board_browser.py +++ b/tests/board_browser.py @@ -9,6 +9,7 @@ from playwright.sync_api import sync_playwright from test_ears import BRAIN, CONFIG, STAMP, board, fixture from test_followthrough import delivery_snapshot +from test_closed_followups import candidate_snapshot def main(): @@ -26,6 +27,14 @@ def main(): snapshot['conversations'][0]['title'] = 'Help configuring multi-band galaxy models' snapshot['conversations'][0]['waiting_since'] = (stamp - timedelta(days=3)).isoformat() snapshot['conversations'][1]['title'] = 'Add a tutorial for the new fitting workflow' + followup = candidate_snapshot()['conversations'][0] + followup.update(number=13, id='example/hub/discussions/13', + url='https://github.com/example/hub/discussions/13', + title='New request on a closed discussion', + waiting_since=(stamp - timedelta(days=1)).isoformat()) + followup['follow_up'].update(since=followup['waiting_since'], + url=followup['url'] + '#discussioncomment-42') + snapshot['conversations'].append(followup) for name, value in board.render(snapshot, CONFIG, BRAIN).items(): (output / name).write_text(value) server = http.server.ThreadingHTTPServer( @@ -46,6 +55,11 @@ def main(): page.emulate_media(color_scheme=scheme) assert not page.evaluate("document.documentElement.scrollWidth > innerWidth"), (width, scheme) page.screenshot(path=str(output / f"synthetic-{width}-{scheme}.png"), full_page=True) + followup_topic = page.locator('#attention .topic').filter(has_text='New request on a closed discussion') + followup_topic.locator(':scope > summary').click() + assert 'Closed · Answered' in followup_topic.inner_text() + assert 'permission' in followup_topic.inner_text() + assert followup_topic.get_by_role('link', name='Read follow-up').get_attribute('href').endswith('#discussioncomment-42') assert not page.locator('#freshness').is_visible() assert page.locator('.orchestration-links a').count() == 2 update = page.locator('.orchestration-panel [data-refresh-link]') diff --git a/tests/test_closed_followups.py b/tests/test_closed_followups.py new file mode 100644 index 0000000..33555a8 --- /dev/null +++ b/tests/test_closed_followups.py @@ -0,0 +1,154 @@ +"""New requests must be visible without requiring a contributor to reopen.""" +import copy +import json + +import pytest + +from test_ears import API, CONFIG, BRAIN, STAMP, thread, user, comments +from test_listening import DiscussionAPI, discussion, node, connection +from ears import collect, board + +CLOSED = '2026-10-02T00:00:00Z' +LATER = '2026-10-03T00:00:00Z' + + +def settled(**kwargs): + return thread(state='closed', closed_at=CLOSED, answer_chosen_at=CLOSED, + category={'name': 'Ideas & Proposals'}, **kwargs) + + +@pytest.mark.parametrize('body', ['Could you add analytic streamed components?', 'Thanks!']) +def test_new_nested_reply_on_closed_answered_thread_is_review_candidate(body): + api = DiscussionAPI({(None, None): connection([node('answer', 'Maintainer', CLOSED)]), + ('answer', None): connection([node('reply', stamp=LATER, databaseId=18676930, body=body)])}, raw=settled()) + row, receipt = discussion(api) + assert row['closed'] and row['answered'] + assert row['awaiting_response'] is True and receipt['status'] == 'complete' + assert row['follow_up'] == {'review_needed': True, 'since': '2026-10-03T00:00:00+00:00', + 'url': 'https://github.com/example/hub/discussions/1#discussioncomment-18676930'} + assert body not in json.dumps(row) # judgment and source bodies stay out of snapshots + + +@pytest.mark.parametrize('state', ['open', 'closed']) +def test_pre_settlement_comments_do_not_reactivate_old_requests(state): + raw = settled(); raw['state'] = state + row, _ = discussion(DiscussionAPI({(None, None): connection([node('old', stamp='2026-10-01T15:00:00Z')])}, raw=raw)) + assert row['follow_up']['review_needed'] is False + assert row['awaiting_response'] is False + + +def test_answered_open_thread_and_later_maintainer_response_then_renewed_followup(): + raw = settled(); raw['state'] = 'open' + nodes = [node('request', stamp=LATER), node('handled', 'Maintainer', STAMP)] + api = DiscussionAPI({(None, None): connection(nodes)}, raw=raw) + assert discussion(api)[0]['follow_up']['review_needed'] is False + api.graph[(None, None)]['nodes'].append(node('another', stamp='2026-10-04T00:00:00Z')) + row, _ = discussion(api) + assert row['follow_up']['review_needed'] is True + assert row['waiting_since'] == '2026-10-04T00:00:00+00:00' + + +@pytest.mark.parametrize('failure', ['timestamp', 'settlement', 'pagination', 'author']) +def test_incomplete_evidence_is_unknown_even_on_closed_answered_threads(failure): + raw = settled() + activity = node('new', stamp=LATER) + graph = {(None, None): connection([activity])} + if failure == 'timestamp': activity['createdAt'] = None + if failure == 'author': activity['author'] = None + if failure == 'settlement': raw.pop('closed_at') + if failure == 'pagination': graph[('new', None)] = collect.ReadError('failed') + row, receipt = discussion(DiscussionAPI(graph, raw=raw)) + assert row['follow_up']['review_needed'] is None + assert row['awaiting_response'] is None + assert row['coverage'] == receipt['status'] == 'partial' + + +def test_graphql_supplies_missing_rest_closure_time(): + api = DiscussionAPI({(None, None): connection([node('new', stamp=LATER)])}, raw=settled()) + api.overrides[next(k for k in api.overrides if 'state=open' in k)][0].pop('closed_at') + api.discussion_state = lambda repo, n: {'closed': True, 'closedAt': CLOSED, 'answerChosenAt': CLOSED} + assert discussion(api)[0]['follow_up']['review_needed'] is True + api.discussion_state = lambda repo, n: {'closed': False, 'closedAt': None, 'answerChosenAt': CLOSED} + assert discussion(api)[0]['follow_up']['review_needed'] is None + + +def closed_listing(repo='example/lib', page=1): + return f'repos/{repo}/issues?state=closed&sort=updated&direction=desc&per_page=100&page={page}' + + +@pytest.mark.parametrize('author', ['Reporter', 'Maintainer']) +def test_closed_issue_comments_are_collected_independently_of_issue_author(author): + api = API({closed_listing(): [thread(login=author, state='closed', closed_at=CLOSED), + thread(2, pull_request={}, state='closed')], + comments(): [{'id': 42, 'user': user('Reporter'), 'created_at': LATER}]}) + snapshot = collect.collect(api, ['example/lib'], CONFIG) + collect.validate(snapshot) + assert len(snapshot['conversations']) == 1 + assert snapshot['conversations'][0]['follow_up']['url'].endswith('#issuecomment-42') + assert not any('/pulls/' in c for c in api.calls) + + +def test_quiet_and_bot_only_closed_issues_do_not_crowd_queue(): + api = API({closed_listing(): [thread(state='closed', closed_at=CLOSED)], + comments(): [{'user': {'login': 'robot[bot]', 'type': 'Bot'}, 'created_at': LATER}]}) + assert collect.collect(api, ['example/lib'], CONFIG)['conversations'] == [] + + +def test_truncated_closed_issue_scan_retains_candidates_and_reports_gap(): + api = API({closed_listing(): [thread(n, state='closed', closed_at=CLOSED) for n in range(1, 101)], + closed_listing(page=2): collect.ReadError('read failed'), + comments(): [{'user': user('Reporter'), 'created_at': LATER}]}) + s = collect.collect(api, ['example/lib'], CONFIG) + assert len(s['conversations']) == 1 + receipt = next(r for r in s['receipts'] if r['repo'] == 'example/lib') + assert receipt['status'] == 'partial' and 'closed issues' in receipt['gaps'][0] + + +def candidate_snapshot(): + api = DiscussionAPI({(None, None): connection([node('new', stamp=LATER, databaseId=18676930)])}, raw=settled()) + s = collect.collect(api, [], CONFIG) + s['generated'] = STAMP + return s + + +def test_board_keeps_thread_status_and_permission_aware_review_visible(): + s = candidate_snapshot() + rendered = board.render(s, CONFIG, BRAIN, STAMP) + assert 'Follow-up needs review' in rendered['index.html'] + assert 'Closed · Answered' in rendered['index.html'] + assert '#discussioncomment-18676930' in rendered['index.html'] + assert 'Contributors may lack' in rendered['index.html'] + assert 'permission' in json.loads(rendered['state.json'])['items'][0]['prompt'] + assert json.loads(rendered['state.json'])['status'] == 'yellow' + assert json.loads(board.render(s, CONFIG, BRAIN, '2026-10-05T00:00:00Z')['state.json'])['status'] == 'stale' + + +@pytest.mark.parametrize('mutation', [ + lambda f: f.update(url='javascript:alert(1)'), + lambda f: f.update(url='https://github.com/other/repo/issues/1'), + lambda f: f.update(review_needed='yes'), + lambda f: f.update(body='must never export'), + lambda f: f.update(since=None), +]) +def test_followup_extension_rejects_unsafe_or_inconsistent_evidence(mutation): + s = candidate_snapshot(); mutation(s['conversations'][0]['follow_up']) + with pytest.raises(ValueError): collect.validate(s) + + +@pytest.mark.parametrize('metadata', [dict(comments=0), dict(comments=5, updated_at=CLOSED)]) +def test_quiet_closed_history_does_not_need_per_issue_comment_requests(metadata): + api = API({closed_listing(): [thread(state='closed', closed_at=CLOSED, **metadata)]}) + assert collect.collect(api, ['example/lib'], CONFIG)['conversations'] == [] + assert comments() not in api.calls + + +def test_discussion_settlement_transport_is_fixed_read_only_query(monkeypatch): + calls = [] + def run(args, **kwargs): + calls.append((args, json.loads(kwargs['input']))) + return type('Result', (), {'returncode': 0, 'stdout': json.dumps({ + 'data': {'repository': {'discussion': {'closed': True, 'closedAt': CLOSED, 'answerChosenAt': CLOSED}}}})})() + monkeypatch.setattr(collect.subprocess, 'run', run) + assert collect.GitHub().discussion_state('example/hub', 13)['closedAt'] == CLOSED + assert calls[0][1]['query'] == collect.DISCUSSION_STATE + assert 'mutation' not in calls[0][1]['query'] diff --git a/tests/test_ears.py b/tests/test_ears.py index 8eef022..1891f2e 100644 --- a/tests/test_ears.py +++ b/tests/test_ears.py @@ -116,7 +116,7 @@ def test_response_age_is_not_generic_updated_at(): assert row["waiting_since"] == "2026-10-02T01:00:00+00:00" -@pytest.mark.parametrize("category,answer,expected", [("Help & Questions", None, None), ("Ideas & Proposals", STAMP, False), ("Show and tell", None, False)]) +@pytest.mark.parametrize("category,answer,expected", [("Help & Questions", None, None), ("Ideas & Proposals", STAMP, None), ("Show and tell", None, False)]) def test_discussion_policy_and_unverified_nested_replies(category, answer, expected): api = API({listing("example/hub", "discussions"): [thread(category={"name": category}, answer_chosen_at=answer)]}) row = collect.collect(api, [], CONFIG)["conversations"][0] diff --git a/tests/test_followthrough.py b/tests/test_followthrough.py index a3865ee..803c1f3 100644 --- a/tests/test_followthrough.py +++ b/tests/test_followthrough.py @@ -105,13 +105,13 @@ def test_untrusted_actor_and_private_link_not_followed_or_exported(): def delivery_snapshot(): a = api_fixture() - raw = thread(1, state='closed', answer_chosen_at=STAMP, category={'name': 'Ideas & Proposals'}) + raw = thread(1, state='closed', closed_at=STAMP, answer_chosen_at=STAMP, category={'name': 'Ideas & Proposals'}) api = DiscussionAPI({ (None, None): connection([node('c1', body='PRIVATE RAW TEXT')]), ('c1', None): connection([node('r1', 'Maintainer', stamp='2026-10-03T13:00:00Z', body='Delivery-issue: ' + I)]), }, overrides={**a.overrides, 'repos/example/hub/discussions?state=open&per_page=100&page=1': [], - 'repos/example/hub/discussions?state=closed&per_page=100&page=1': [raw]}) + 'repos/example/hub/discussions?state=closed&sort=updated&direction=desc&per_page=100&page=1': [raw]}) snap = collect.collect(api, [], CONFIG, set()) return snap