Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ keeps source/delivery badges and makes no plan claim.
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
and after the last maintainer response needs review, with the latest such comment
no more than 30 days before collection. 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
Expand All @@ -227,6 +228,21 @@ 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.

For complete observations, an unanswered post-settlement follow-up whose latest
external human comment is **older than 30 days** moves out of Needs your attention
and into Community activity as **Historical follow-up**. The optional top-level
`historical_follow_up_at` records that latest comment timestamp; `follow_up` is
inactive (`review_needed: false`, `since: null`, `url: null`), `awaiting_response`
is false and `waiting_since` is null. This is an attention-window policy, not a
claim that someone replied or resolved the request. Historical closed issues stay
in the snapshot. The exact 30-day boundary remains recent. A new external human
comment restores attention; bots and generic issue updates do not renew it.
Ordinary open issues and unsettled Discussions have no age limit. Missing or
incomplete activity remains unknown, never historical. Legacy snapshots retain
their original observations until a new collection; render time does not change
them. Brain accepts the additive historical timestamp and existing inactive
follow-up shape, so its awaiting-response queue agrees with the Ears board.

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;
Expand Down
7 changes: 6 additions & 1 deletion ears/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ def section(title, key, selected, empty):
candidate = row['follow_up'].get('url')
if candidate:
details += '<p><a href="' + esc(candidate, quote=True) + '">Read follow-up ↗</a></p>'
details += '<p>Activity needs review; this is not a decision to reopen. Contributors may lack reopening permission.</p>'
historical = row.get('historical_follow_up_at')
if historical:
details += '<p><strong>Last external follow-up:</strong> <time datetime="' + esc(historical, quote=True) + '">' + esc(utc(historical).strftime('%d %b %Y, %H:%M UTC')) + '</time></p>'
details += '<p>Older than 30 days; kept here as history.</p>'
else:
details += '<p>Activity needs review; this is not a decision to reopen. Contributors may lack reopening permission.</p>'
if row.get('waiting_since'):
details += '<p><strong>Waiting since:</strong> <time datetime="' + esc(row['waiting_since'], quote=True) + '">' + esc(utc(row['waiting_since']).strftime('%d %b %Y, %H:%M UTC')) + '</time></p>'
if stale:
Expand Down
31 changes: 26 additions & 5 deletions ears/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import json
import re
import subprocess
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path

REPO = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\Z")
BROADCAST = {"Announcements", "Show and tell"}
FOLLOW_UP_WINDOW = timedelta(days=30)
DISCUSSION_COMMENTS = """query($owner:String!, $name:String!, $number:Int!, $after:String) {
repository(owner:$owner, name:$name) { discussion(number:$number) {
comments(first:100, after:$after) {
Expand Down Expand Up @@ -312,7 +313,7 @@ def validate_follow_up(row):
raise ValueError("inconsistent follow-up response")


def conversation(api, repo, raw, kind, config, deliveries=None, pending=None):
def conversation(api, repo, raw, kind, config, deliveries=None, pending=None, observed_at=None):
number = raw.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ReadError("invalid conversation number")
Expand Down Expand Up @@ -351,6 +352,7 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None):
category = (raw.get("category") or {}).get("name")
answered = raw.get("answer_chosen_at") is not None
observation = None
historical_follow_up_at = 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
Expand All @@ -372,6 +374,16 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None):
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 True:
# Only complete evidence reaches here. A fresh external reply renews
# attention even when the oldest pending comment is years old.
comment_events, _ = activity_events(comments)
latest = max(utc(t) for t, who in comment_events
if who.casefold() not in selves and utc(t) >= utc(since))
if utc(observed_at or now()) - latest > FOLLOW_UP_WINDOW:
historical_follow_up_at = latest.isoformat()
awaiting, since = False, None
observation = {"review_needed": False, "since": None, "url": None}
if awaiting is None:
complete = False
gaps.append("post-settlement activity or settlement time unknown")
Expand All @@ -397,6 +409,7 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None):
"gaps": gaps, "cached": False,
"feedback_report": "<!-- feedback-report: v1 -->" in (raw.get("body") or ""),
**({"follow_up": observation} if observation is not None else {}),
**({"historical_follow_up_at": historical_follow_up_at} if historical_follow_up_at else {}),
}


Expand Down Expand Up @@ -463,7 +476,8 @@ def collect(api, repo_homes, config, pending=None):
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)
row = conversation(api, repo, entry, item_kind, config, deliveries, pending,
observed_at=stamp)
except ReadError as exc:
receipt["gaps"].append(str(exc))
continue
Expand All @@ -475,7 +489,8 @@ def collect(api, repo_homes, config, pending=None):
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:
if item_kind == "issue" and row.get("closed") and row["awaiting_response"] is False \
and not row.get("historical_follow_up_at"):
continue
source_rows.append(row)
receipt["status"] = "partial" if receipt["gaps"] else "complete"
Expand Down Expand Up @@ -516,7 +531,7 @@ def validate(snapshot):
for row in rows:
if not isinstance(row, dict):
raise ValueError("invalid conversation")
if set(row) - {"closed", "follow_up", "created_at", "updated_at"} != {"id", "repo", "number", "kind", "url", "title", "author", "category", "answered",
if set(row) - {"closed", "follow_up", "created_at", "updated_at", "historical_follow_up_at"} != {"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:
Expand All @@ -534,6 +549,12 @@ def validate(snapshot):
raise ValueError("invalid or duplicate conversation identity/link")
ids.add(identity)
validate_follow_up(row)
if "historical_follow_up_at" in row:
historical = utc(row["historical_follow_up_at"])
if row.get("follow_up") != {"review_needed": False, "since": None, "url": None} \
or row.get("coverage") != "complete" or row.get("waiting_since") is not None \
or utc(snapshot["generated"]) - historical <= FOLLOW_UP_WINDOW:
raise ValueError("invalid historical follow-up evidence")
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"):
Expand Down
2 changes: 2 additions & 0 deletions ears/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ def progress(row, delivery, stale, plans):


def waiting_label(row, observed):
if row.get('historical_follow_up_at'):
return 'Historical follow-up'
if row.get('follow_up', {}).get('review_needed') is True:
return 'Follow-up needs review'
since = row.get('waiting_since')
Expand Down
14 changes: 13 additions & 1 deletion tests/board_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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
from test_closed_followups import candidate_snapshot, historical_snapshot


def main():
Expand All @@ -36,6 +36,11 @@ def main():
followup['follow_up'].update(since=followup['waiting_since'],
url=followup['url'] + '#discussioncomment-42')
snapshot['conversations'].append(followup)
historical = historical_snapshot()['conversations'][0]
historical.update(number=7, id='example/lib/issues/7',
url='https://github.com/example/lib/issues/7',
title='Old follow-up on a closed issue')
snapshot['conversations'].append(historical)
for name, value in board.render(snapshot, CONFIG, BRAIN).items():
(output / name).write_text(value)
server = http.server.ThreadingHTTPServer(
Expand Down Expand Up @@ -95,6 +100,13 @@ def main():
assert "community" in copied and "https://github.com/example/lib/issues/1" in copied
page.locator('.board-nav-card[href="#activity"]').click()
page.wait_for_function("document.getElementById('activity').closest('details.board-section').open")
historical_topic = page.locator('#activity .topic').filter(has_text='Old follow-up on a closed issue')
assert historical_topic.count() == 1
assert page.locator('#attention .topic').filter(has_text='Old follow-up on a closed issue').count() == 0
assert 'Historical follow-up' in historical_topic.locator('xpath=ancestor::tr').inner_text()
historical_topic.locator(':scope > summary').click()
assert 'Older than 30 days' in historical_topic.inner_text()
assert 'Activity needs review' not in historical_topic.inner_text()
page.locator('#activity .topic summary').first.click()
follow = page.get_by_role("button", name="Copy follow-through prompt").first
follow.click()
Expand Down
106 changes: 104 additions & 2 deletions tests/test_closed_followups.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""New requests must be visible without requiring a contributor to reopen."""
import copy
import json
from datetime import timedelta
from unittest.mock import patch

import pytest

Expand All @@ -12,6 +14,11 @@
LATER = '2026-10-03T00:00:00Z'


@pytest.fixture(autouse=True)
def fixed_collection_time(monkeypatch):
monkeypatch.setattr(collect, 'now', lambda: STAMP)


def settled(**kwargs):
return thread(state='closed', closed_at=CLOSED, answer_chosen_at=CLOSED,
category={'name': 'Ideas & Proposals'}, **kwargs)
Expand Down Expand Up @@ -106,11 +113,106 @@ def test_truncated_closed_issue_scan_retains_candidates_and_reports_gap():

def candidate_snapshot():
api = DiscussionAPI({(None, None): connection([node('new', stamp=LATER, databaseId=18676930)])}, raw=settled())
s = collect.collect(api, [], CONFIG)
s['generated'] = STAMP
with patch('ears.collect.now', return_value=STAMP):
s = collect.collect(api, [], CONFIG)
return s


def historical_snapshot(kind='issue', latest='2020-02-02T00:00:00Z', extra=()):
raw = thread(state='closed', created_at='2020-01-01T00:00:00Z',
closed_at='2020-02-01T00:00:00Z', updated_at=STAMP)
if kind != 'issue':
raw.update(answer_chosen_at=raw['closed_at'], category={'name': 'Help & Questions'})
if kind == 'answered':
raw['state'] = 'open'
api = DiscussionAPI({(None, None): connection([
node('old', stamp='2020-02-02T00:00:00Z'),
node('latest', stamp=latest), *extra])}, raw=raw)
else:
api = API({closed_listing(): [raw], comments(): [
{'id': 41, 'user': user('Reporter'), 'created_at': '2020-02-02T00:00:00Z'},
{'id': 42, 'user': user('Reporter'), 'created_at': latest}, *extra]})
with patch('ears.collect.now', return_value=STAMP):
snapshot = collect.collect(api, ['example/lib'] if kind == 'issue' else [], CONFIG)
collect.validate(snapshot)
return snapshot


@pytest.mark.parametrize('kind', ['issue', 'discussion', 'answered'])
@pytest.mark.parametrize('seconds_old,historical', [(30 * 86400 + 1, True), (30 * 86400, False), (29 * 86400, False)])
def test_cutoff_uses_latest_external_followup_not_oldest_pending_or_thread_update(kind, seconds_old, historical):
latest = (collect.utc(STAMP) - timedelta(seconds=seconds_old)).isoformat()
s = historical_snapshot(kind, latest)
row = s['conversations'][0]
assert row['awaiting_response'] is not historical
assert row['follow_up']['review_needed'] is not historical
if historical:
assert row['historical_follow_up_at'] == latest
assert row['waiting_since'] is None
assert row['follow_up'] == {'review_needed': False, 'since': None, 'url': None}
else:
assert 'historical_follow_up_at' not in row
assert row['waiting_since'] == '2020-02-02T00:00:00+00:00'


def test_historical_followup_stays_in_activity_and_out_of_feed_attention():
s = historical_snapshot()
result = board.render(s, CONFIG, BRAIN, STAMP)
attention, activity = result['index.html'].split('id="activity"', 1)
assert '<details class="topic">' not in attention
assert 'Historical follow-up' in activity
assert 'Last external follow-up:' in activity
assert 'Activity needs review' not in activity
assert 'Historical follow-up' in result['dashboard.md']
state = json.loads(result['state.json'])
assert state['status'] == 'green'
assert not state['items']
adapter = board.load_module(BRAIN / 'agents/conductors/community/_ears_feed.py', 'ears_window_adapter')
adapted = adapter.adapt(s, state, CONFIG['self_logins'], 'example', CONFIG['hub'], current=collect.utc(STAMP))
assert adapted['counts']['awaiting_response'] == 0


def test_new_reply_reactivates_history_and_bots_do_not():
bot = {'user': {'login': 'robot[bot]', 'type': 'Bot'}, 'created_at': STAMP}
assert historical_snapshot(extra=[bot])['conversations'][0]['historical_follow_up_at']
fresh = {'user': user('AnotherReporter'), 'created_at': STAMP}
row = historical_snapshot(extra=[fresh])['conversations'][0]
assert row['awaiting_response'] is True and 'historical_follow_up_at' not in row
maintainer = {'user': user('Maintainer'), 'created_at': STAMP}
assert historical_snapshot(extra=[maintainer])['conversations'] == []


def test_old_open_issue_is_not_aged_out():
from test_ears import listing
s = collect.collect(API({listing(): [thread(created_at='2020-01-01T00:00:00Z')]}), ['example/lib'], CONFIG)
assert s['conversations'][0]['awaiting_response'] is True
assert 'historical_follow_up_at' not in s['conversations'][0]


@pytest.mark.parametrize('extra', [
{'user': user('Reporter'), 'created_at': None},
{'user': None, 'created_at': STAMP},
])
def test_old_incomplete_activity_cannot_be_classified_as_historical(extra):
row = historical_snapshot(extra=[extra])['conversations'][0]
assert row['awaiting_response'] is None and row['coverage'] == 'partial'
assert 'historical_follow_up_at' not in row


@pytest.mark.parametrize('mutation', [
lambda r: r.update(historical_follow_up_at=STAMP),
lambda r: r.update(historical_follow_up_at=None),
lambda r: r.update(coverage='partial'),
lambda r: r.update(waiting_since=LATER),
lambda r: r.pop('follow_up'),
lambda r: r.update(closed=False),
])
def test_historical_metadata_must_have_complete_expired_settled_evidence(mutation):
s = historical_snapshot(); mutation(s['conversations'][0])
with pytest.raises(ValueError):
collect.validate(s)


def test_board_keeps_thread_status_and_permission_aware_review_visible():
s = candidate_snapshot()
rendered = board.render(s, CONFIG, BRAIN, STAMP)
Expand Down
Loading