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
8 changes: 8 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ flag, nullable `awaiting_response`, nullable UTC `waiting_since`, boolean
format flag. `null` is unknown, never false. This is a read contract, not a
second task registry. No runtime task assignments or opinions are persisted.

Optional nullable `created_at` and `updated_at` fields retain source timestamps,
normalized to UTC. Missing or malformed source dates become null; older snapshots
may omit these fields. Both conversation tables show Updated, with an explicitly
labelled creation-date fallback or Unavailable. These dates never substitute for
response timing (`waiting_since`) or the snapshot's observation time. Brain's
Community adapter accepts these additive metadata fields without changing its
response heuristics.

Each source receipt names the repo, UTC check time, verified-public flag,
status (complete/partial/unavailable/excluded) and bounded gap descriptions.
Only a successful repository metadata response with `private: false` permits
Expand Down
55 changes: 34 additions & 21 deletions ears/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,47 +118,61 @@ def render_page(snapshot, theme, headline, stale, fresh_until, attention, unknow
def section(title, key, selected, empty):
body.append(f'<section id="{key}"><div class="section-head"><h2>{esc(title)} '
f'<span class="section-count">{len(selected)}</span></h2></div>')
md.extend(['## ' + title, '', '| Topic | Repository | Author | Type | Progress | Response |',
'| --- | --- | --- | --- | --- | --- |'])
md.extend(['## ' + title, '', '| Topic | Repository | Author | Type | Updated | Progress | Response |',
'| --- | --- | --- | --- | --- | --- | --- |'])
if not selected:
body.append(f'<p class="empty">{esc(empty)}</p></section>')
md.extend([empty, ''])
return
body.append(f'<div class="table-wrap" role="region" aria-label="{esc(title)}" tabindex="0">'
'<table class="community"><thead><tr><th scope="col">Conversation / repository</th>'
'<th scope="col">Author</th><th scope="col">Progress</th><th scope="col">Response</th>'
body.append('<p class="table-hint">Scroll the table to see all columns →</p>'
f'<div class="table-wrap" role="region" aria-label="{esc(title)}" tabindex="0">'
'<table class="community"><thead><tr><th scope="col">Conversation</th>'
'<th scope="col">Author</th><th scope="col">Updated</th>'
'<th scope="col">Progress</th><th scope="col">Response</th>'
'<th scope="col">Actions</th></tr></thead><tbody>')
for row in selected:
d = linked.get(row['url'])
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(triage_prompt(row))
details = '<p><a href="' + esc(row['url'], quote=True) + '">Open ' + kind.lower() + ' ↗</a></p>'
timestamp = row.get('updated_at') or row.get('created_at')
date = utc(timestamp).strftime('%d %b %Y') if timestamp else 'Unavailable'
date_html = ('<time datetime="' + esc(timestamp, quote=True) + '">' + date + '</time>'
if timestamp else date)
if timestamp and not row.get('updated_at'):
date += ' (created)'
date_html += ' (created)'
details = ''
if 'follow_up' in row:
settled = 'Closed' if row.get('closed') else 'Open'
settled += ' · Answered' if row['answered'] else ''
details += '<p>Thread status: ' + settled + '</p>'
details += '<p><strong>Thread status:</strong> ' + settled + '</p>'
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>'
if row.get('waiting_since'):
details += '<p>Waiting since <time datetime="' + esc(row['waiting_since'], quote=True) + '">' + esc(utc(row['waiting_since']).strftime('%d %b %Y, %H:%M UTC')) + '</time></p>'
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:
details += '<p>Response and progress observations are stale. Refresh before acting.</p>'
if row['category']:
details += '<p>Category: ' + esc(row['category']) + '</p>'
details += '<p><strong>Category:</strong> ' + esc(row['category']) + '</p>'
if row['feedback_report']:
details += '<p>Feedback report (format marker only)</p>'
for gap in row['gaps']:
details += '<p>' + esc(line(gap)) + '</p>'
details += '<p><strong>Feedback report:</strong> format marker only</p>'
if row['gaps']:
details += '<p><strong>Source gaps:</strong></p><ul>' + ''.join(
'<li>' + esc(line(gap)) + '</li>' for gap in row['gaps']) + '</ul>'
if d:
details += '<p>Delivery: ' + esc('unknown (stale observation)' if stale else d['state'].replace('_', ' ')) + '</p>'
for evidence in d['evidence']:
details += '<p><a href="' + esc(evidence['url'], quote=True) + '">' + esc(evidence['kind'] + ': ' + evidence['url']) + '</a></p>'
for gap in d['gaps']:
details += '<p>' + esc(line(gap)) + '</p>'
details += '<p><strong>Delivery:</strong> ' + esc('unknown (stale observation)' if stale else d['state'].replace('_', ' ')) + '</p>'
if d['evidence']:
details += '<p><strong>Delivery evidence:</strong></p><ul>' + ''.join(
'<li><a href="' + esc(evidence['url'], quote=True) + '">' +
esc(evidence['kind'] + ': ' + evidence['url']) + '</a></li>'
for evidence in d['evidence']) + '</ul>'
if d['gaps']:
details += '<p><strong>Evidence gaps:</strong></p><ul>' + ''.join(
'<li>' + esc(line(gap)) + '</li>' for gap in d['gaps']) + '</ul>'
follow = theme.portable_prompt('/community triage ' + row['url'] + ' — verify linked delivery evidence and draft a contributor update for approval; do not post')
details += copy_button(follow, 'Copy follow-through prompt')
details += '<details><summary>Triage prompt</summary><pre>' + esc(prompt) + '</pre></details>'
Expand All @@ -168,15 +182,15 @@ def section(title, key, selected, empty):
if label == 'Plan recorded':
details += '<p>An issued Mind record explicitly links this issue and contains a plan.</p>'
body.append('<tr><td><details class="topic"><summary>' + esc(row['title']) +
'<span class="topic-meta">' + esc(row['repo']) + ' · ' + kind + ' #' + str(row['number']) +
'</span></summary><div class="topic-body">' + details + '</div></details></td><td>' + author_html +
'</summary><div class="topic-body">' + details + '</div></details></td><td>' + author_html +
'</td><td class="conversation-date">' + date_html +
'</td><td>' + pill(label, tone) + note + '</td><td>' + esc(timing) +
('<span class="small-note">At last observation</span>' if stale else '') +
'</td><td><div class="row-actions">' + copy_button(prompt, 'Copy triage prompt') +
'<a class="source-action" aria-label="Open conversation on GitHub" title="Open conversation on GitHub" href="' +
esc(row['url'], quote=True) + '">↗</a></div></td></tr>')
md_title = row["title"].replace("[", "&#91;").replace("]", "&#93;")
values = [f"[{md_title}]({row['url']})", row['repo'], row['author'] or 'Unavailable', kind, label, timing]
values = [f"[{md_title}]({row['url']})", row['repo'], row['author'] or 'Unavailable', kind, date, label, timing]
md.append('| ' + ' | '.join(esc(v).replace('|', '&#124;').replace('\n', ' ') for v in values) + ' |')
body.append('</tbody></table></div></section>')
md.append('')
Expand All @@ -185,7 +199,6 @@ def section(title, key, selected, empty):
'No attention items found in the observed data. Check coverage before concluding nobody is waiting.')
section('Community activity', 'activity', [r for r in rows if r not in attention],
'No additional conversations in the observed data.')
body.append('<details><summary>Recurring feedback</summary><p>Planned: evidence-backed themes across independent reports. No themes have been inferred by this collector.</p></details>')
body.append('<p id="copy-status" role="status" aria-live="polite"></p><pre id="copy-fallback" tabindex="-1" hidden></pre>')
body.append('<section id="coverage"><h2>Listening coverage</h2><div class="table-wrap" role="region" aria-label="Listening coverage" tabindex="0">'
'<table class="coverage"><thead><tr><th scope="col">Repository</th><th scope="col">Coverage</th>'
Expand Down
15 changes: 14 additions & 1 deletion ears/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ def now():
return datetime.now(timezone.utc).isoformat()


def source_timestamp(value):
"""Keep valid public source dates; absent or malformed metadata is unknown."""
try:
return utc(value).isoformat()
except (ValueError, TypeError):
return None


def line(value):
return " ".join(str(value or "").split())[:500]

Expand Down Expand Up @@ -381,6 +389,8 @@ def conversation(api, repo, raw, kind, config, deliveries=None, pending=None):
"closed": raw.get("state") == "closed",
"author": line((raw.get("user") or {}).get("login")),
"category": line(category), "answered": answered,
"created_at": source_timestamp(raw.get("created_at")),
"updated_at": source_timestamp(raw.get("updated_at")),
"awaiting_response": awaiting, "waiting_since": since,
"review_requested": bool(selves.intersection(reviewers)),
"coverage": "complete" if complete and awaiting is not None else "partial",
Expand Down Expand Up @@ -506,7 +516,7 @@ def validate(snapshot):
for row in rows:
if not isinstance(row, dict):
raise ValueError("invalid conversation")
if set(row) - {"closed", "follow_up"} != {"id", "repo", "number", "kind", "url", "title", "author", "category", "answered",
if set(row) - {"closed", "follow_up", "created_at", "updated_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 @@ -528,6 +538,9 @@ def validate(snapshot):
raise ValueError("invalid awaiting-response state")
if row.get("waiting_since"):
utc(row["waiting_since"])
for key in ("created_at", "updated_at"):
if row.get(key) is not None:
utc(row[key])
if row.get("coverage") not in {"complete", "partial"}:
raise ValueError("invalid conversation coverage")
for key in ("title", "author", "category"):
Expand Down
10 changes: 6 additions & 4 deletions ears/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,14 @@ def triage_prompt(row):
.checkin details{margin-top:14px}.checkin textarea{min-height:170px;line-height:1.55;resize:vertical}
.section-head{display:flex;align-items:center;gap:10px}.section-head h2{flex:1;margin-bottom:12px}.section-count{font-size:.8rem;border-radius:20px;padding:2px 9px;background:var(--tint);color:var(--accent)}
.table-wrap{width:100%;overflow-x:auto;border:1px solid var(--line);border-radius:12px}
.table-hint{display:none}
table.community,table.coverage{width:100%;border-collapse:collapse;text-align:left;table-layout:fixed;font-size:.88rem}
th{padding:12px 14px;color:var(--muted);font-size:.73rem;letter-spacing:.04em;text-transform:uppercase;background:var(--btn);font-weight:650}
td{padding:14px;border-top:1px solid var(--line);vertical-align:top}tbody tr:hover{background:var(--tint)}
.community th:nth-child(1){width:38%}.community th:nth-child(2){width:17%}.community th:nth-child(3){width:17%}.community th:nth-child(4){width:16%}.community th:nth-child(5){width:12%}
.community th:nth-child(1){width:32%}.community th:nth-child(2){width:14%}.community th:nth-child(3){width:13%}.community th:nth-child(4){width:15%}.community th:nth-child(5){width:14%}.community th:nth-child(6){width:12%}
.topic summary{padding:0;color:var(--fg);font-weight:650;line-height:1.5}.topic summary::marker{color:var(--accent)}
.topic-meta{display:block;font-size:.78rem;color:var(--muted);font-weight:400;margin:5px 0 0 17px}
.topic-body{padding:12px 0 0 17px;font-size:.85rem}.topic-body p{margin:7px 0}.topic-body ul{padding-left:17px}
.topic-body{padding:4px 0 0 17px;font-size:.88rem;line-height:1.65}.topic-body p{margin:12px 0}.topic-body strong{font-weight:700}.topic-body ul{padding-left:18px;margin:6px 0 16px}.topic-body li+li{margin-top:8px}.topic-body>details{margin-top:16px}
.conversation-date{font-variant-numeric:tabular-nums}.conversation-date time{white-space:nowrap}
.topic-body pre{padding:12px;background:var(--btn);border:1px solid var(--line);border-radius:8px;white-space:pre-wrap;font-size:.8rem}
.author{font-weight:600}.author-empty{color:var(--muted)}.badge{display:inline-block;padding:4px 8px;border:1px solid currentColor;border-radius:6px;font-size:.75rem;font-weight:600;line-height:1.4;background:var(--bg)}
.small-note{display:block;margin-top:6px;font-size:.75rem;color:var(--muted)}.owed-note{color:var(--accent);font-weight:600}
Expand All @@ -172,5 +173,6 @@ def triage_prompt(row):
#copy-status{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:10;max-width:90vw;border-radius:10px;background:var(--fg);color:var(--bg);box-shadow:0 4px 24px #0003;padding:12px 18px;margin:0}#copy-status:empty{display:none}
#copy-fallback{white-space:pre-wrap;padding:16px;border:1px solid var(--edge);background:var(--btn)}
@media(prefers-color-scheme:dark){.blue{color:#83b4ff}.purple{color:#c4a0ff}}
@media(max-width:760px){body{padding:0 16px 32px}.checkin{padding:18px}.checkin-head{display:block}.hub-link{margin:0 0 16px}.checkin-controls{display:block}.checkin-controls button{margin-top:12px;width:100%}table.community{min-width:740px}table.coverage{min-width:650px}.table-wrap:focus-visible{outline-offset:2px}}
@media(max-width:960px){table.community{min-width:860px}.table-hint{display:block;font-size:.8rem;margin:0 0 8px}}
@media(max-width:760px){body{padding:0 16px 32px}.checkin{padding:18px}.checkin-head{display:block}.hub-link{margin:0 0 16px}.checkin-controls{display:block}.checkin-controls button{margin-top:12px;width:100%}table.coverage{min-width:650px}.table-wrap:focus-visible{outline-offset:2px}}
'''
11 changes: 10 additions & 1 deletion tests/board_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def main():
# Use realistic public-metadata lengths without making live network reads.
snapshot['conversations'][0]['title'] = 'Help configuring multi-band galaxy models'
snapshot['conversations'][0]['waiting_since'] = (stamp - timedelta(days=3)).isoformat()
snapshot['conversations'][0]['updated_at'] = stamp.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',
Expand Down Expand Up @@ -107,7 +108,15 @@ def main():
assert page.evaluate('window.getSelection().toString()') == page.locator('#copy-fallback').inner_text()
for width in (390, 1280):
page.set_viewport_size({'width': width, 'height': 900})
assert not page.evaluate('document.documentElement.scrollWidth > innerWidth')
for scheme in ('light', 'dark'):
page.emulate_media(color_scheme=scheme)
assert not page.evaluate('document.documentElement.scrollWidth > innerWidth')
assert page.locator('#attention th', has_text='Updated').count() == 1
assert page.locator('#activity th', has_text='Updated').count() == 1
assert page.locator('.topic-meta').count() == 0
assert page.get_by_text('Recurring feedback', exact=True).count() == 0
assert page.locator('.topic-body strong', has_text='Delivery:').first.is_visible()
page.screenshot(path=str(output / f'expanded-{width}-{scheme}.png'), full_page=True)
# Re-opening an expired page must suppress current delivery claims.
page.evaluate("Date.now = () => Date.parse(expires) + 1; window.dispatchEvent(new Event('pageshow'))")
assert page.locator('#freshness').is_visible()
Expand Down
25 changes: 25 additions & 0 deletions tests/test_ears.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,31 @@ def fixture():
return s


@pytest.mark.parametrize('kind', ['issue', 'pr', 'discussion'])
def test_source_dates_are_normalized_independently_of_response_timing(kind):
raw = thread(created_at='2026-10-01T13:00:00+01:00', updated_at=STAMP)
if kind == 'pr':
raw['pull_request'] = {}
repo = 'example/hub' if kind == 'discussion' else 'example/lib'
endpoint = 'discussions' if kind == 'discussion' else 'issues'
snapshot = collect.collect(API({listing(repo, endpoint): [raw]}), ['example/lib'], CONFIG)
row = snapshot['conversations'][0]
assert row['kind'] == kind
assert row['created_at'] == '2026-10-01T12:00:00+00:00'
assert row['updated_at'] == '2026-10-03T12:00:00+00:00'
collect.validate(snapshot)
row['updated_at'] = '<script>bad date</script>'
with pytest.raises(ValueError):
collect.validate(snapshot)


@pytest.mark.parametrize('value', [None, '', 'not a date', '2026-10-01T12:00:00', 42])
def test_unusable_source_dates_are_unknown(value):
snapshot = collect.collect(API({listing(): [thread(updated_at=value)]}), ['example/lib'], CONFIG)
assert snapshot['conversations'][0]['updated_at'] is None
collect.validate(snapshot)


def test_public_only_and_failed_metadata_never_leak_titles():
api = API({"repos/private/lib": {"private": True},
"repos/unknown/lib": collect.ReadError("read unavailable"),
Expand Down
Loading
Loading