From 68bb06da1e5193b6faeac1e64bc7ffda483af092 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 18 Jul 2026 18:55:27 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(inbox):=20make=20it=20agent-actionable?= =?UTF-8?q?=20=E2=80=94=20mark-read=20+=20rich=20JSON=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actions: --read (repeatable) marks notification(s) read via notificationUpdate; --read-all via notificationMarkReadAll. Richer --json: each notification now carries issue{identifier,url,state}, comment{id,url,body}, parentComment (thread), actor, and the notification url/inboxUrl — enough for an agent to follow the ticket/thread and act (reply via 'linear update --comment'). Listing prints the follow-up recipe. 0.12.0 -> 0.13.0; adds a mark-read unit test. --- CHANGELOG.md | 14 +++++++++++ linear | 63 ++++++++++++++++++++++++++++++++++++++++++++------ test_linear.py | 23 ++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bd997..a9d14c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.0] - 2026-07-19 + +### Added + +- `linear inbox` is now agent-actionable: + - **Actions:** `--read ` marks specific notification(s) read (repeatable); + `--read-all` marks everything read (via `notificationUpdate` / + `notificationMarkReadAll`). + - **Richer `--json`:** each notification now carries the issue (`identifier`, + `url`, `state`), the comment (`id`, `url`, `body`), the thread `parentComment`, + and the actor — enough for an agent to follow the ticket/thread and act. + - The plain listing prints the follow-up recipe: reply with + `linear update --comment "..."`, dismiss with `linear inbox --read `. + ## [0.12.0] - 2026-07-18 ### Added diff --git a/linear b/linear index 1167c40..965ef56 100755 --- a/linear +++ b/linear @@ -37,7 +37,7 @@ from pathlib import Path from urllib.request import Request, urlopen from urllib.error import URLError -__version__ = "0.12.0" +__version__ = "0.13.0" # Sentinel for "flag not supplied" — distinct from None, which means an explicit # clear (e.g. `--project none`). Lets update pre-resolve a field once and pass @@ -2771,9 +2771,51 @@ _INBOX_TYPE_LABEL = { } +def _mark_notifications_read(api_key, ids): + """Mark specific notifications read via notificationUpdate. Returns (ok, failed).""" + import datetime + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + ok, failed = [], [] + for nid in ids: + data = gql(api_key, """ + mutation($id: String!, $at: DateTime!) { + notificationUpdate(id: $id, input: { readAt: $at }) { success } + } + """, {"id": nid, "at": now}) + success = (((data.get("data") or {}).get("notificationUpdate") or {}).get("success")) + (ok if success and "errors" not in data else failed).append(nid) + return ok, failed + + def cmd_inbox(args, cfg, api_key, team_id): - """Show your Linear inbox — notifications: comments, mentions, assignments, - status changes on issues you follow. Unread only by default; --all adds read.""" + """Show your Linear inbox — notifications (comments, mentions, assignments, + status changes). Acts too: --read marks notification(s) read, --read-all + marks everything read. Unread only by default; --all lists read as well. + + Agents: `--json` returns full metadata per notification — the issue + (identifier + url + state), the comment (id + url + body), the thread parent, + and the actor — enough to follow up. Reply on a ticket with + `linear update --comment "..."`; dismiss with + `linear inbox --read `.""" + # --- actions take precedence over listing --- + if getattr(args, "read_all", False): + data = gql(api_key, "mutation { notificationMarkReadAll { success } }") + if check_errors(data): + return + ok = (((data.get("data") or {}).get("notificationMarkReadAll") or {}).get("success")) + print("Marked all notifications read." if ok else "Failed to mark all read.") + return + read_ids = getattr(args, "read", None) or [] + if read_ids: + ok, failed = _mark_notifications_read(api_key, read_ids) + if ok: + print(f"Marked read ({len(ok)}): {', '.join(ok)}") + if failed: + print(f"Failed to mark read: {', '.join(failed)}", file=sys.stderr) + sys.exit(1) + return + + # --- list --- limit = getattr(args, "limit", 30) # argparse always supplies a default; honor an explicit value if limit < 1: print("inbox: --limit must be >= 1", file=sys.stderr) @@ -2784,9 +2826,10 @@ def cmd_inbox(args, cfg, api_key, team_id): nodes { id __typename createdAt readAt ... on IssueNotification { - type - issue { identifier title url state { name } } - comment { body } + type url inboxUrl + issue { identifier title url state { name type } } + comment { id url body } + parentComment { id url } actor { name displayName } } } @@ -2824,6 +2867,8 @@ def cmd_inbox(args, cfg, api_key, team_id): body = ((n.get("comment") or {}).get("body") or "").replace("\n", " ").strip() if body and label in ("comment", "mention"): print(f' "{body[:96]}"') + print('\nReply: linear update --comment "..." · ' + 'Dismiss: linear inbox --read (id in --json)') def cmd_agents(args, cfg, api_key, team_id): @@ -3056,12 +3101,16 @@ def main(): p_states.add_argument("--json", action="store_true", help="JSON output") # inbox (Linear notifications: comments, mentions, assignments, status changes) - p_inbox = sub.add_parser("inbox", help="Show your Linear inbox (notifications)") + p_inbox = sub.add_parser("inbox", help="Show your Linear inbox (notifications), or mark them read") p_inbox.add_argument("--all", action="store_true", help="Include already-read notifications (default: unread only)") p_inbox.add_argument("--limit", type=int, default=30, help="Max notifications to fetch (default: 30)") p_inbox.add_argument("--json", action="store_true", help="JSON output") + p_inbox.add_argument("--read", action="append", metavar="NOTIFICATION_ID", + help="Mark the given notification id(s) read (repeatable). id is in --json output.") + p_inbox.add_argument("--read-all", action="store_true", + help="Mark ALL notifications read") # Back-compat + ergonomics: `projects ` (a bare name, not a verb) is # shorthand for `projects show `. Insert the implicit `show` so the diff --git a/test_linear.py b/test_linear.py index 2b96e27..f0a3fdd 100644 --- a/test_linear.py +++ b/test_linear.py @@ -94,6 +94,29 @@ def fake_gql(_key, _query, _vars=None): self.assertEqual(len(actor_rows), 2) self.assertEqual(len({ln.index("Bisma") for ln in actor_rows}), 1) # aligned + def test_cmd_inbox_read_marks_each_given_notification(self): + # --read issues a notificationUpdate mutation per id and reports them. + calls = [] + + def fake_gql(_key, query, variables=None): + calls.append(variables) + self.assertIn("notificationUpdate", query) + return {"data": {"notificationUpdate": {"success": True}}} + + args = types.SimpleNamespace(read=["n1", "n2"], read_all=False, + limit=30, all=False, json=False) + original_gql = linear_cli.gql + linear_cli.gql = fake_gql + stdout = io.StringIO() + try: + with contextlib.redirect_stdout(stdout): + linear_cli.cmd_inbox(args, {}, "api-key", "team-id") + finally: + linear_cli.gql = original_gql + + self.assertEqual([c["id"] for c in calls], ["n1", "n2"]) # one mutation per id + self.assertIn("Marked read (2): n1, n2", stdout.getvalue()) + class BulkUpdateTest(unittest.TestCase): def test_collect_update_identifiers_dedupes_positional_and_stdin_in_order(self): From 45e9d3e47e24313e13fcb2900f9081ce6b996b6b Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 18 Jul 2026 18:58:46 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(inbox):=20address=20prix=20=E2=80=94=20?= =?UTF-8?q?surface=20mutation=20errors=20+=20exit=20non-zero=20on=20--read?= =?UTF-8?q?-all=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. _mark_notifications_read now calls check_errors(data) per iteration so a bad id / permission / rate-limit error prints 'Error: ' to stderr, matching the other per-item mutation loops (was swallowing data['errors']). 2. --read-all now prints failure to stderr and sys.exit(1) on success:false, consistent with --read and the projectDelete convention. --- linear | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/linear b/linear index 965ef56..096ef69 100755 --- a/linear +++ b/linear @@ -2782,8 +2782,11 @@ def _mark_notifications_read(api_key, ids): notificationUpdate(id: $id, input: { readAt: $at }) { success } } """, {"id": nid, "at": now}) + # check_errors prints "Error: " to stderr on a GraphQL error, + # matching the other per-item mutation loops in this file. + had_error = check_errors(data) success = (((data.get("data") or {}).get("notificationUpdate") or {}).get("success")) - (ok if success and "errors" not in data else failed).append(nid) + (ok if success and not had_error else failed).append(nid) return ok, failed @@ -2800,10 +2803,12 @@ def cmd_inbox(args, cfg, api_key, team_id): # --- actions take precedence over listing --- if getattr(args, "read_all", False): data = gql(api_key, "mutation { notificationMarkReadAll { success } }") - if check_errors(data): - return - ok = (((data.get("data") or {}).get("notificationMarkReadAll") or {}).get("success")) - print("Marked all notifications read." if ok else "Failed to mark all read.") + ok = not check_errors(data) and (((data.get("data") or {}).get("notificationMarkReadAll") or {}).get("success")) + if ok: + print("Marked all notifications read.") + else: + print("Failed to mark all read.", file=sys.stderr) + sys.exit(1) return read_ids = getattr(args, "read", None) or [] if read_ids: