diff --git a/REFERENCE.md b/REFERENCE.md index 328d246..3b42361 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -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 diff --git a/ears/board.py b/ears/board.py index 79c7d3c..c819353 100644 --- a/ears/board.py +++ b/ears/board.py @@ -118,15 +118,17 @@ def render_page(snapshot, theme, headline, stale, fresh_until, attention, unknow def section(title, key, selected, empty): body.append(f'

{esc(title)} ' f'{len(selected)}

') - 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'

{esc(empty)}

') md.extend([empty, '']) return - body.append(f'
' - '' - '' + body.append('

Scroll the table to see all columns →

' + f'
' + '
Conversation / repositoryAuthorProgressResponse
' + '' + '' '') for row in selected: d = linked.get(row['url']) @@ -134,31 +136,43 @@ def section(title, key, selected, empty): timing = waiting_label(row, snapshot['generated']) kind = {'issue': 'Issue', 'pr': 'PR', 'discussion': 'Discussion'}[row['kind']] prompt = theme.portable_prompt(triage_prompt(row)) - details = '

Open ' + kind.lower() + ' ↗

' + timestamp = row.get('updated_at') or row.get('created_at') + date = utc(timestamp).strftime('%d %b %Y') if timestamp else 'Unavailable' + date_html = ('' + 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 += '

Thread status: ' + settled + '

' + 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

' + details += '

Waiting since:

' if stale: details += '

Response and progress observations are stale. Refresh before acting.

' if row['category']: - details += '

Category: ' + esc(row['category']) + '

' + details += '

Category: ' + esc(row['category']) + '

' if row['feedback_report']: - details += '

Feedback report (format marker only)

' - for gap in row['gaps']: - details += '

' + esc(line(gap)) + '

' + details += '

Feedback report: format marker only

' + if row['gaps']: + details += '

Source gaps:

' if d: - details += '

Delivery: ' + esc('unknown (stale observation)' if stale else d['state'].replace('_', ' ')) + '

' - for evidence in d['evidence']: - details += '

' + esc(evidence['kind'] + ': ' + evidence['url']) + '

' - for gap in d['gaps']: - details += '

' + esc(line(gap)) + '

' + details += '

Delivery: ' + esc('unknown (stale observation)' if stale else d['state'].replace('_', ' ')) + '

' + if d['evidence']: + details += '

Delivery evidence:

' + if d['gaps']: + details += '

Evidence gaps:

' 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 += '
Triage prompt
' + esc(prompt) + '
' @@ -168,15 +182,15 @@ def section(title, key, selected, empty): if label == 'Plan recorded': details += '

An issued Mind record explicitly links this issue and contains a plan.

' body.append('') md_title = row["title"].replace("[", "[").replace("]", "]") - 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('|', '|').replace('\n', ' ') for v in values) + ' |') body.append('
ConversationAuthorUpdatedProgressResponseActions
' + esc(row['title']) + - '' + esc(row['repo']) + ' · ' + kind + ' #' + str(row['number']) + - '
' + details + '
' + author_html + + '
' + details + '
' + author_html + + '' + date_html + '' + pill(label, tone) + note + '' + esc(timing) + ('At last observation' if stale else '') + '
' + copy_button(prompt, 'Copy triage prompt') + '↗
') md.append('') @@ -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('
Recurring feedback

Planned: evidence-backed themes across independent reports. No themes have been inferred by this collector.

') body.append('

') body.append('

Listening coverage

' '' diff --git a/ears/collect.py b/ears/collect.py index c31b69b..cc84248 100644 --- a/ears/collect.py +++ b/ears/collect.py @@ -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] @@ -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", @@ -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: @@ -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"): diff --git a/ears/presentation.py b/ears/presentation.py index e0e4a20..19013b4 100644 --- a/ears/presentation.py +++ b/ears/presentation.py @@ -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} @@ -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}} ''' diff --git a/tests/board_browser.py b/tests/board_browser.py index e901ab3..0849e3e 100644 --- a/tests/board_browser.py +++ b/tests/board_browser.py @@ -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', @@ -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() diff --git a/tests/test_ears.py b/tests/test_ears.py index 1891f2e..1bc7f5e 100644 --- a/tests/test_ears.py +++ b/tests/test_ears.py @@ -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'] = '' + 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"), diff --git a/tests/test_presentation.py b/tests/test_presentation.py index 93f9911..f35f98e 100644 --- a/tests/test_presentation.py +++ b/tests/test_presentation.py @@ -80,13 +80,42 @@ def test_plan_hints_require_explicit_issued_record_and_nonempty_plan(tmp_path): def test_old_snapshots_still_render_and_plan_hints_do_not_change_feed(): snapshot = fixture() snapshot.pop('follow_through', None) + for row in snapshot['conversations']: + row.pop('created_at', None) + row.pop('updated_at', None) baseline = board.render(snapshot, CONFIG, BRAIN, rendered_at=STAMP) + assert '' in baseline['index.html'] with_plan = board.render(snapshot, CONFIG, BRAIN, rendered_at=STAMP, plans={snapshot['conversations'][0]['url']}) assert '>Plan recorded<' in with_plan['index.html'] assert baseline['state.json'] == with_plan['state.json'] assert baseline['badge.json'] == with_plan['badge.json'] +def test_conversation_dates_in_both_tables_do_not_change_feed_or_waiting_age(): + snapshot = fixture() + from copy import deepcopy + activity = deepcopy(snapshot['conversations'][0]) + activity.update(id='example/lib/issues/2', number=2, + url='https://github.com/example/lib/issues/2', + awaiting_response=False, waiting_since=None) + snapshot['conversations'].append(activity) + baseline = board.render(snapshot, CONFIG, BRAIN, rendered_at=STAMP) + snapshot['conversations'][0]['updated_at'] = STAMP + result = board.render(snapshot, CONFIG, BRAIN, rendered_at=STAMP) + page = result['index.html'] + assert page.count('') == 2 + assert '' in page + assert '01 Oct 2026 (created)' in page + assert '03 Oct 2026' in result['dashboard.md'] + assert '01 Oct 2026 (created)' in result['dashboard.md'] + assert '2d waiting' in page + assert result['state.json'] == baseline['state.json'] + assert 'topic-meta' not in page + assert 'Open issue ↗' not in page + assert 'Recurring feedback' not in page + assert page.count('aria-label="Open conversation on GitHub"') == 2 + + def test_waiting_age_uses_observation_not_render_time(): row = fixture()['conversations'][0] assert waiting_label(row, STAMP) == '2d waiting'
RepositoryCoverageUnavailableUpdated