From 62f8f264c6359c955eef861fc245c21a09d733f5 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 21 Aug 2026 17:11:16 -0400 Subject: [PATCH] feat: dashboard per-paper browsing + one-tap read/cite/intake actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading-queue sections on the dashboard now expand to the individual papers. Each title links out โ€” arXiv abstract page when the queue line carries a ` โ€” ` ref, arXiv title search otherwise โ€” and carries three prefilled-GitHub-issue actions (nothing changes state until the human submits the issue on GitHub): - ๐Ÿ“ฅ intake into memory (queue-intake): really interesting โ€” the issue stays open as the full wiki-filing work item, carrying the human's free-text notes. - ๐Ÿ“‘ make citeable (queue-cite): worth citing, not pivotal โ€” bib entry plus a minimal sources section with the notes. - โœ… read, don't file (queue-read): the new queue_actions.yml processes it via scripts/queue_mark_done.py โ€” the line gets a `DONE โ€” ` prefix (the reading history; never deleted), the commit stages reading-queue.md only, the dashboard re-render is redispatched, and the issue closes. Gated on owner/member/collaborator authorship โ€” a stranger's issue never mutates the queue. Badge and README strip are byte-identical (the cross-board contract). The snapshot's queue entries gain a "papers" list; DONE papers render as a collapsed per-section reading history. Closes #42. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K21CJX9TAgULDo1fFJtonc --- .github/workflows/queue_actions.yml | 87 ++++++++++++ scripts/board.py | 204 ++++++++++++++++++++++++---- scripts/queue_mark_done.py | 94 +++++++++++++ tests/test_board.py | 58 +++++++- tests/test_queue_mark_done.py | 58 ++++++++ 5 files changed, 474 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/queue_actions.yml create mode 100644 scripts/queue_mark_done.py create mode 100644 tests/test_queue_mark_done.py diff --git a/.github/workflows/queue_actions.yml b/.github/workflows/queue_actions.yml new file mode 100644 index 0000000..ac2bff7 --- /dev/null +++ b/.github/workflows/queue_actions.yml @@ -0,0 +1,87 @@ +name: Queue actions + +# The dashboard's per-paper โœ… button opens a prefilled `queue-read` issue; +# this workflow marks that paper's reading-queue.md line +# `DONE โ€” ` (never deleted โ€” the reading history), pushes the +# commit, re-renders the dashboard, and closes the issue. ๐Ÿ“ฅ `queue-intake` +# and ๐Ÿ“‘ `queue-cite` issues are NOT handled here โ€” they stay open as filing +# work items (full wiki filing / bib-entry-plus-minimal-section). +# +# Only owner/member/collaborator-authored issues act: this is a public repo, +# and a stranger's issue must never mutate the queue (they cannot apply the +# label either, but the gate does not rely on that). + +on: + issues: + types: [opened] + +# contents: write โ†’ the queue commit; issues: write โ†’ comment + close; +# actions: write โ†’ re-dispatch the Dashboard workflow (token pushes do not +# retrigger `on: push` workflows). +permissions: + contents: write + issues: write + actions: write + +concurrency: + group: queue-actions + cancel-in-progress: false + +jobs: + mark-read: + if: >- + contains(github.event.issue.labels.*.name, 'queue-read') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), + github.event.issue.author_association) + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + ISSUE: ${{ github.event.issue.number }} + steps: + - uses: actions/checkout@v4 + + - name: Mark the queue line DONE + id: mark + env: + # env, never inline interpolation โ€” the body is untrusted text. + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + printf '%s' "$ISSUE_BODY" > /tmp/issue_body.txt + status=$(python scripts/queue_mark_done.py \ + --queue reading-queue.md --body-file /tmp/issue_body.txt) || true + echo "status=${status}" >> "$GITHUB_OUTPUT" + echo "queue_mark_done: ${status}" + + - name: Commit + push (explicit path only) + if: steps.mark.outputs.status == 'marked' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add reading-queue.md + git commit -m "queue: mark paper read (#${ISSUE}) [skip ci]" + for attempt in 1 2 3; do + if git push; then exit 0; fi + git pull --rebase origin main || exit 1 + done + echo "::error::could not push the queue commit after 3 attempts" + exit 1 + + - name: Refresh the dashboard + if: steps.mark.outputs.status == 'marked' + run: gh workflow run knowledge_board.yml --ref main + + - name: Close or report on the issue + env: + STATUS: ${{ steps.mark.outputs.status }} + run: | + case "$STATUS" in + marked) + gh issue close "$ISSUE" --comment \ + "Marked read in reading-queue.md โ€” the line stays, DONE-prefixed (the reading history). Dashboard refresh dispatched." ;; + already-done) + gh issue close "$ISSUE" --comment \ + "Already marked read in reading-queue.md โ€” nothing to change." ;; + *) + gh issue comment "$ISSUE" --body \ + "Could not process this queue action (status: ${STATUS:-error}) โ€” the paper's line was not found in its section of reading-queue.md. Handle manually; leaving the issue open." ;; + esac diff --git a/scripts/board.py b/scripts/board.py index a161b58..81f093c 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -6,7 +6,14 @@ block holding a paste-ready Claude Code prompt that executes this repo's own documented workflow (``bibliography/README.md`` "Adding a paper", ``wiki/CLAUDE.md``'s schema) โ€” plus contents cards with ``/memory <domain>`` -recall chips. +recall chips. Reading-queue sections expand to the individual papers: each +title links out (arXiv abstract page, or a title search when the line has no +ref) and carries three prefilled-GitHub-issue actions โ€” ๐Ÿ“ฅ intake-into-memory +(really interesting: full filing), ๐Ÿ“‘ make-citeable (worth citing, not +pivotal: bib entry + minimal sources section), both open work items carrying +the human's free-text notes, and โœ… read-don't-file (processed automatically +by ``queue_actions.yml`` via ``queue_mark_done.py``). Nothing changes state +until the human submits the issue. **Contents-level only.** The board shows titles and counts, never claim text or summaries โ€” the knowledge itself stays in the wiki pages. @@ -35,6 +42,7 @@ import subprocess import sys from pathlib import Path +from urllib.parse import quote as _quote MEMORY_HOME = Path(__file__).resolve().parents[1] @@ -48,6 +56,11 @@ # queue is also the reading history. QUEUE_SECTION_RE = re.compile(r"^##\s+(.+?)\s*$") QUEUE_DONE_RE = re.compile(r"^DONE\b") +QUEUE_DONE_LINE_RE = re.compile(r"^DONE\s+(\d{4}-\d{2}-\d{2})\s*โ€”\s*(.*)$") +# A paper line may end ` โ€” <arXiv id or URL>`; anything else after an em dash +# is part of the title. +QUEUE_REF_RE = re.compile( + r"^(.*\S)\s+โ€”\s+((?:arXiv:)?\d{4}\.\d{4,5}(?:v\d+)?|https?://\S+)$") def _owner_repo() -> tuple[str, str]: @@ -113,21 +126,22 @@ def collect(root: Path | None = None) -> dict: queue = root / "reading-queue.md" if queue.exists(): - section, count, done = None, 0, 0 + section, papers = None, [] def _flush(): if section is not None: - snapshot["queue"].append({"section": section, "count": count, - "done": done}) + snapshot["queue"].append({ + "section": section, + "count": sum(1 for p in papers if not p["done"]), + "done": sum(1 for p in papers if p["done"]), + "papers": papers, + }) for line in queue.read_text(errors="replace").splitlines(): m = QUEUE_SECTION_RE.match(line) if m and not m.group(1).startswith("#"): _flush() - section, count, done = m.group(1), 0, 0 + section, papers = m.group(1), [] elif section is not None and line.strip(): - if QUEUE_DONE_RE.match(line.strip()): - done += 1 - else: - count += 1 + papers.append(_parse_paper(line.strip())) _flush() slugs = [s.split("|")[0].strip() for s in all_slugs] @@ -141,6 +155,91 @@ def _flush(): # --- pure helpers -------------------------------------------------------------- +def _parse_paper(text: str) -> dict: + """One queue line โ†’ {title, ref, done, done_date, line} (line = as written).""" + raw, done_date = text, None + m = QUEUE_DONE_LINE_RE.match(text) + if m: + done_date, text = m.group(1), m.group(2) + elif QUEUE_DONE_RE.match(text): + done_date, text = "", text[4:].lstrip(" โ€”-") + ref = None + m = QUEUE_REF_RE.match(text) + if m: + text, ref = m.group(1), m.group(2) + return {"title": text, "ref": ref, "done": done_date is not None, + "done_date": done_date or None, "line": raw} + + +def paper_url(paper: dict) -> str: + """Where to read the paper: its abstract page, or an arXiv title search.""" + ref = paper.get("ref") + if ref: + if ref.startswith("http"): + return ref + return "https://arxiv.org/abs/" + ref.removeprefix("arXiv:") + return ("https://arxiv.org/search/?searchtype=title&query=" + + _quote(paper.get("title", ""), safe="")) + + +def _queue_issue_url(snapshot: dict, section: str, paper: dict, + action: str) -> str: + """A prefilled new-issue URL for a per-paper action. + + Three tiers โ€” 'intake' (really interesting: full wiki filing), 'cite' + (worth citing, not pivotal: bib entry + a minimal sources section), and + 'read' (done with it: DONE-mark only, processed automatically by + queue_actions.yml). Nothing changes state until the human submits the + issue on GitHub; intake/cite issues stay open as the filing work item, + and their `notes:` field is free text the human edits before submitting. + Empty when the checkout has no remote (spawned templates). + """ + repo_url = _repo_url(snapshot) + if not repo_url: + return "" + where = f"section: {section}\nline: {paper['line']}" + notes = ("notes: (optional โ€” replace this with why you added it or " + "what was noteworthy, in your own words; whoever files the " + "paper folds it in)") + if action == "read": + issue_title = f"queue read: {paper['title']}"[:200] + label = "queue-read" + body = ("Mark this reading-queue paper as read without filing it " + "(the line stays, DONE-prefixed โ€” the reading history).\n\n" + f"{where}\n\n" + "Processed automatically by queue_actions.yml.") + elif action == "cite": + issue_title = f"queue cite: {paper['title']}"[:200] + label = "queue-cite" + body = ("Make this reading-queue paper citeable โ€” read, worth " + "citing, but not pivotal enough for full wiki treatment.\n\n" + f"{where}\n\n" + f"{notes}\n\n" + "Workflow (bibliography/README.md \"Adding a paper\", " + "wiki/CLAUDE.md): verify the paper against an authoritative " + "record, add its canonical entry to the bibliography/ BibTeX " + "file, add a minimal section to the matching " + "wiki/<domain>/sources/ page โ€” canonical key plus the notes " + "above, nothing deeper โ€” mark its queue line " + "'DONE <date> โ€” <title>' in reading-queue.md, and run " + "make validate.") + else: + issue_title = f"queue intake: {paper['title']}"[:200] + label = "queue-intake" + body = ("File this reading-queue paper into memory.\n\n" + f"{where}\n\n" + f"{notes}\n\n" + "Workflow (bibliography/README.md \"Adding a paper\", " + "wiki/CLAUDE.md): verify the paper against an authoritative " + "record, add its canonical entry to the bibliography/ BibTeX " + "file, stub it in the matching wiki/<domain>/sources/ page โ€” " + "incorporating the notes above โ€” mark its queue line " + "'DONE <date> โ€” <title>' in reading-queue.md, and run " + "make validate.") + return (f"{repo_url}/issues/new?title={_quote(issue_title, safe='')}" + f"&body={_quote(body, safe='')}&labels={_quote(label, safe='')}") + + def _totals(snapshot: dict) -> dict: wikis = snapshot.get("wikis") or [] statuses: dict[str, int] = {} @@ -216,7 +315,20 @@ def _render_md(snapshot: dict) -> str: lines += ["", "## Reading queue", ""] for q in snapshot.get("queue") or []: done = f", {q['done']} read" if q.get("done") else "" - lines.append(f"- {q['section']}: {q['count']} waiting{done}") + lines += [f"<details><summary>{q['section']}: {q['count']} " + f"waiting{done}</summary>", ""] + for p in q.get("papers") or []: + if p["done"]: + continue + label = p["title"].replace("[", "\\[").replace("]", "\\]") + bits = [f"- [{label}]({paper_url(p)})"] + for action, chip in (("intake", "intake ๐Ÿ“ฅ"), ("cite", "cite ๐Ÿ“‘"), + ("read", "read โœ…")): + u = _queue_issue_url(snapshot, q["section"], p, action) + if u: + bits.append(f"[{chip}]({u})") + lines.append(" ยท ".join(bits)) + lines += ["", "</details>"] if not snapshot.get("queue"): lines.append("- _(queue empty or unavailable)_") url = pages_url(snapshot) @@ -241,7 +353,7 @@ def _copy_btn(payload: str, label: str = "copy") -> str: return (f"<button class='copy' type='button' " f"title='{_html.escape(label, quote=True)}' " f"data-copy=\"{_html.escape(payload, quote=True)}\" " - f"onclick='cp(this)'>๐Ÿ“‹</button>") + f"onclick='cp(this,event)'>๐Ÿ“‹</button>") def _bar(statuses: dict) -> str: @@ -259,17 +371,45 @@ def _render_html(snapshot: dict) -> str: pct = round(100 * t["resolved"] / t["sections"]) if t["sections"] else 0 repo_url = _repo_url(snapshot) - queue_rows = [] + queue_blocks = [] for q in snapshot.get("queue") or []: - done = (f" <span class='meta'>ยท {q['done']} read</span>" - if q.get("done") else "") - queue_rows.append( - f"<tr><td class='name'>{_html.escape(q['section'])}</td>" - f"<td>{q['count']} waiting{done} " + done = (f" ยท {q['done']} read" if q.get("done") else "") + items = [] + hist_items = [] + for p in q.get("papers") or []: + if p["done"]: + hist_items.append( + f"<li class='done'>DONE {_html.escape(p.get('done_date') or '?')}" + f" โ€” {_html.escape(p['title'])}</li>") + continue + acts = [] + for action, icon, hint in ( + ("intake", "๐Ÿ“ฅ", "intake into memory: really interesting โ€” " + "opens a prefilled issue (add your notes) " + "that becomes the full filing work item"), + ("cite", "๐Ÿ“‘", "make citeable: worth citing, not pivotal โ€” " + "opens a prefilled issue (add your notes) " + "for a bib entry + minimal sources section"), + ("read", "โœ…", "read โ€” don't file: opens a prefilled issue; " + "submitting marks this line DONE")): + u = _queue_issue_url(snapshot, q["section"], p, action) + if u: + acts.append(f"<a class='act' href=\"{_html.escape(u, quote=True)}\" " + f"title='{_html.escape(hint, quote=True)}'>{icon}</a>") + items.append( + f"<li><a href=\"{_html.escape(paper_url(p), quote=True)}\">" + f"{_html.escape(p['title'])}</a>{''.join(acts)}</li>") + hist = (f"<details class='hist'><summary class='meta'>reading history " + f"({len(hist_items)})</summary><ul class='papers'>" + f"{''.join(hist_items)}</ul></details>" if hist_items else "") + queue_blocks.append( + f"<details class='qsec'><summary><span class='name'>" + f"{_html.escape(q['section'])}</span> <span class='meta'>" + f"{q['count']} waiting{done}</span> " f"{_copy_btn(_read_prompt(snapshot, q['section']), 'copy: file the next paper')}" - f"</td></tr>") - if not queue_rows: - queue_rows.append("<tr><td colspan='2'>queue empty or unavailable</td></tr>") + f"</summary><ul class='papers'>{''.join(items)}</ul>{hist}</details>") + if not queue_blocks: + queue_blocks.append("<p class='meta'>queue empty or unavailable</p>") todo_rows = [] for w in snapshot.get("wikis") or []: @@ -328,10 +468,25 @@ def _render_html(snapshot: dict) -> str: color: #c9d1d9; cursor: pointer; padding: .05rem .45rem; margin-left: .35rem; font-size: .85rem; line-height: 1.4; }} button.copy:hover {{ background: #30363d; }} + details.qsec {{ border-top: 1px solid #21262d; padding: .5rem .25rem; }} + details.qsec > summary {{ cursor: pointer; list-style: none; }} + details.qsec > summary::-webkit-details-marker {{ display: none; }} + details.qsec > summary .name::before {{ content: "โ–ธ "; color: #8b949e; }} + details.qsec[open] > summary .name::before {{ content: "โ–พ "; }} + .name {{ font-weight: 600; }} + ul.papers {{ margin: .5rem 0 .25rem; padding-left: 1.3rem; }} + ul.papers li {{ margin: .4rem 0; }} + ul.papers li.done {{ color: #8b949e; }} + a.act {{ margin-left: .35rem; padding: .05rem .35rem; font-size: .85rem; + border: 1px solid #30363d; border-radius: 6px; background: #21262d; }} + a.act:hover {{ background: #30363d; text-decoration: none; }} + details.hist {{ margin: .25rem 0 .25rem 1.3rem; }} + details.hist > summary {{ cursor: pointer; }} footer {{ margin-top: 2rem; color: #8b949e; font-size: .8rem; }} </style> <script> -function cp(b){{var t=b.getAttribute('data-copy'); +function cp(b,e){{if(e){{e.preventDefault();e.stopPropagation();}} + var t=b.getAttribute('data-copy'); if(navigator.clipboard&&navigator.clipboard.writeText){{ navigator.clipboard.writeText(t).then(function(){{ok(b)}},function(){{fb(t)}}); }}else{{fb(t)}}}} @@ -343,9 +498,12 @@ def _render_html(snapshot: dict) -> str: <p><span class="pill">{t['pages']} pages ยท {pct}% cited</span> <span class="meta"><a href="dashboard.md">markdown version</a></span></p> <p class="meta">Contents and work queues for the organism's long-term memory โ€” titles and counts only; the knowledge itself lives in the wiki pages. - ๐Ÿ“‹ copies a paste-ready prompt for a Claude Code chat.</p> + ๐Ÿ“‹ copies a paste-ready prompt for a Claude Code chat; on a paper, the + buttons open a prefilled GitHub issue โ€” edit in your notes, submit to act: + ๐Ÿ“ฅ intake into memory (really interesting), ๐Ÿ“‘ make citeable (worth citing, + not pivotal), โœ… read โ€” don't file (marked DONE automatically).</p> <h2>Reading queue <span class='meta'>({t['queued']} papers waiting)</span></h2> - <table>{''.join(queue_rows)}</table> + {''.join(queue_blocks)} <h2>Citation work queue <span class='meta'>({t['todo']} sections need a canonical key)</span></h2> <table>{''.join(todo_rows)}</table> <h2>Sub-wikis <span class='meta'>({snapshot.get('bib_entries', 0)} bibliography entries ยท diff --git a/scripts/queue_mark_done.py b/scripts/queue_mark_done.py new file mode 100644 index 0000000..ca997cf --- /dev/null +++ b/scripts/queue_mark_done.py @@ -0,0 +1,94 @@ +"""scripts/queue_mark_done.py โ€” mark a reading-queue paper as read. + +Consumed by ``.github/workflows/queue_actions.yml``: the dashboard's per-paper +โœ… button opens a prefilled ``queue-read`` issue whose body names the section +and the exact queue line; this script prefixes that line with +``DONE <date> โ€” `` (the reading-history convention โ€” lines are never deleted). +Stdlib-only and idempotent: an already-DONE line is a success, not an error. + +Prints exactly one status token on stdout โ€” ``marked`` / ``already-done`` +(exit 0), ``bad-body`` (exit 2), ``not-found`` (exit 3). + +Usage: + python scripts/queue_mark_done.py --queue reading-queue.md \ + --body-file /tmp/issue_body.txt [--date YYYY-MM-DD] +""" + +from __future__ import annotations + +import argparse +import datetime +import re +import sys +from pathlib import Path + +SECTION_RE = re.compile(r"^##\s+(.+?)\s*$") +BODY_SECTION_RE = re.compile(r"^section:\s*(.+?)\s*$", re.MULTILINE) +BODY_LINE_RE = re.compile(r"^line:\s*(.+?)\s*$", re.MULTILINE) + + +def parse_body(body: str) -> tuple[str, str] | None: + """The (section, line) named by a queue-action issue body, or None.""" + m_section = BODY_SECTION_RE.search(body) + m_line = BODY_LINE_RE.search(body) + if not (m_section and m_line): + return None + return m_section.group(1), m_line.group(1) + + +def mark_done(text: str, section: str, line: str, date: str) -> tuple[str, str]: + """Prefix the paper's line with ``DONE <date> โ€” `` inside its section. + + Returns (new_text, status) with status in marked / already-done / + not-found. Only the named section is searched, so the same title in two + sections never cross-marks. + """ + out, in_section, status = [], False, "not-found" + done_form = re.compile( + r"DONE \d{4}-\d{2}-\d{2} โ€” " + re.escape(line.strip())) + for raw in text.splitlines(): + m = SECTION_RE.match(raw) + if m: + in_section = m.group(1) == section + elif in_section and status == "not-found": + s = raw.strip() + if s == line.strip(): + out.append(f"DONE {date} โ€” {s}") + status = "marked" + continue + if done_form.fullmatch(s): + status = "already-done" + out.append(raw) + new_text = "\n".join(out) + if text.endswith("\n"): + new_text += "\n" + return new_text, status + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="scripts/queue_mark_done.py", + description=__doc__) + ap.add_argument("--queue", required=True, help="path to reading-queue.md") + ap.add_argument("--body-file", required=True, + help="file holding the issue body") + ap.add_argument("--date", default=None, help="override today (YYYY-MM-DD)") + ns = ap.parse_args(argv) + + parsed = parse_body(Path(ns.body_file).read_text(errors="replace")) + if parsed is None: + print("bad-body") + return 2 + section, line = parsed + + date = ns.date or datetime.datetime.now(datetime.timezone.utc).date().isoformat() + queue = Path(ns.queue) + new_text, status = mark_done(queue.read_text(errors="replace"), + section, line, date) + if status == "marked": + queue.write_text(new_text) + print(status) + return {"marked": 0, "already-done": 0}.get(status, 3) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_board.py b/tests/test_board.py index eb8cbd9..a7db9b7 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -39,11 +39,18 @@ def _tree(tmp_path: Path) -> Path: "@book{Other2021,\n title={U}\n}\n") (tmp_path / "reading-queue.md").write_text( "# Reading queue\n\nintro prose\n\n---\n\n" - "## Demo Papers\n\nTitle One\nTitle Two\n" + "## Demo Papers\n\nTitle One\nTitle Two โ€” 2406.01234\n" "DONE 2026-06-01 โ€” Old Title\n\n## Other Things\n\nTitle Three\n") return tmp_path +def _snap_with_remote(tmp_path): + """A synthetic snapshot with repo identity, as a live checkout has.""" + snap = board.collect(_tree(tmp_path)) + snap["owner"], snap["repo"] = "PyAutoLabs", "PyAutoMemory" + return snap + + def test_counts_from_a_synthetic_tree(tmp_path): snap = board.collect(_tree(tmp_path)) (w,) = snap["wikis"] @@ -52,8 +59,18 @@ def test_counts_from_a_synthetic_tree(tmp_path): assert w["statuses"] == {"drafted": 1, "stub": 1} assert w["sections"] == 2 and w["todo"] == 1 and w["resolved_keys"] == 1 assert snap["bib_entries"] == 2 - assert snap["queue"] == [{"section": "Demo Papers", "count": 2, "done": 1}, - {"section": "Other Things", "count": 1, "done": 0}] + assert snap["queue"] == [ + {"section": "Demo Papers", "count": 2, "done": 1, "papers": [ + {"title": "Title One", "ref": None, "done": False, + "done_date": None, "line": "Title One"}, + {"title": "Title Two", "ref": "2406.01234", "done": False, + "done_date": None, "line": "Title Two โ€” 2406.01234"}, + {"title": "Old Title", "ref": None, "done": True, + "done_date": "2026-06-01", + "line": "DONE 2026-06-01 โ€” Old Title"}]}, + {"section": "Other Things", "count": 1, "done": 0, "papers": [ + {"title": "Title Three", "ref": None, "done": False, + "done_date": None, "line": "Title Three"}]}] # alpha exists; beta/gamma are wanted assert snap["links"]["wanted"] == 2 @@ -73,13 +90,46 @@ def test_contents_level_only_no_body_text_leaks(tmp_path): def test_work_queue_prompts_reference_the_documented_workflow(tmp_path): snap = board.collect(_tree(tmp_path)) html = board.render(snap, "html") - assert "data-copy=" in html and "cp(this)" in html + assert "data-copy=" in html and "cp(this,event)" in html assert "reading-queue.md" in html # file-the-next-paper prompt assert "make validate" in html # every prompt ends at the gate assert "/memory demo" in html # the recall chip assert "wiki/CLAUDE.md" in html # the schema anchor +def test_paper_links_and_issue_actions(tmp_path): + html = board.render(_snap_with_remote(tmp_path), "html") + # ref'd paper โ†’ its abstract page; bare title โ†’ an arXiv title search + assert "https://arxiv.org/abs/2406.01234" in html + assert ("https://arxiv.org/search/?searchtype=title&query=Title%20One" + in html) + # both prefilled-issue actions, on the repo's own issue tracker + assert "https://github.com/PyAutoLabs/PyAutoMemory/issues/new?" in html + assert "labels=queue-read" in html and "labels=queue-intake" in html + assert "labels=queue-cite" in html + # a DONE paper renders as history, with no action buttons on its line + assert "DONE 2026-06-01 โ€” Old Title" in html + assert "reading history (1)" in html + # the issue body round-trips the exact queue line for queue_mark_done.py + from urllib.parse import quote + assert quote("line: Title Two โ€” 2406.01234", safe="") in html + + +def test_no_issue_links_without_a_remote(tmp_path): + """A spawned-template checkout (no remote) renders without dead buttons.""" + html = board.render(board.collect(_tree(tmp_path)), "html") + assert "issues/new" not in html + assert "arxiv.org/search" in html # paper links still work + + +def test_md_lists_papers(tmp_path): + md = board.render(_snap_with_remote(tmp_path), "md") + assert "<details><summary>Demo Papers: 2 waiting, 1 read</summary>" in md + assert "[Title One](https://arxiv.org/search/" in md + assert "labels=queue-read" in md and "labels=queue-intake" in md + assert "labels=queue-cite" in md + + def test_md_brief_is_one_line(tmp_path): out = board.render(board.collect(_tree(tmp_path)), "md-brief") assert "\n" not in out and "pages" in out diff --git a/tests/test_queue_mark_done.py b/tests/test_queue_mark_done.py new file mode 100644 index 0000000..f2ce6cb --- /dev/null +++ b/tests/test_queue_mark_done.py @@ -0,0 +1,58 @@ +"""tests/test_queue_mark_done.py โ€” the queue-read issue processor. + +The contract: the exact line named by a `queue-read` issue body gets a +`DONE <date> โ€” ` prefix inside its own section only, the transform is +idempotent (already-DONE is a success), and anything unmatched is reported โ€” +never guessed at. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import queue_mark_done as qmd # noqa: E402 + +QUEUE = ("# Reading queue\n\nintro\n\n" + "## Alpha\n\nShared Title\nOther Paper โ€” 2406.01234\n" + "DONE 2026-06-01 โ€” Old Title\n\n" + "## Beta\n\nShared Title\n") + + +def test_marks_only_the_named_section(): + new, status = qmd.mark_done(QUEUE, "Beta", "Shared Title", "2026-08-21") + assert status == "marked" + assert new.count("DONE 2026-08-21 โ€” Shared Title") == 1 + # Alpha's identical title is untouched + alpha = new.split("## Beta")[0] + assert "DONE 2026-08-21" not in alpha + + +def test_ref_suffixed_line_matches_verbatim(): + new, status = qmd.mark_done(QUEUE, "Alpha", "Other Paper โ€” 2406.01234", + "2026-08-21") + assert status == "marked" + assert "DONE 2026-08-21 โ€” Other Paper โ€” 2406.01234" in new + + +def test_already_done_is_idempotent(): + once, _ = qmd.mark_done(QUEUE, "Alpha", "Shared Title", "2026-08-21") + twice, status = qmd.mark_done(once, "Alpha", "Shared Title", "2026-08-22") + assert status == "already-done" and twice == once + + +def test_unknown_line_or_section_is_not_found(): + assert qmd.mark_done(QUEUE, "Alpha", "No Such Paper", "2026-08-21")[1] \ + == "not-found" + assert qmd.mark_done(QUEUE, "Gamma", "Shared Title", "2026-08-21")[1] \ + == "not-found" + + +def test_parse_body_round_trip(): + body = ("Mark this reading-queue paper as read without filing it.\n\n" + "section: Alpha\nline: Other Paper โ€” 2406.01234\n\n" + "Processed automatically by queue_actions.yml.") + assert qmd.parse_body(body) == ("Alpha", "Other Paper โ€” 2406.01234") + assert qmd.parse_body("no structured fields here") is None