From 3bc79b8339274dc7e8f1e02945c2f4dc00983499 Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 18 Jul 2026 18:39:56 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(inbox):=20add=20'linear=20inbox'=20?= =?UTF-8?q?=E2=80=94=20show=20Linear=20notifications=20from=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New command surfaces your Linear inbox (comments, mentions, assignments, status changes) via the GraphQL notifications query — no browser needed. Unread-only by default; --all includes read, --limit bounds the fetch, --json for raw output. Bumps 0.11.1 -> 0.12.0; adds InboxTypeLabelTest. --- CHANGELOG.md | 9 ++++++ linear | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- test_linear.py | 16 ++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e5865..64bd997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.12.0] - 2026-07-18 + +### Added + +- `linear inbox` — show your Linear inbox (notifications): comments, mentions, + assignments, and status changes on issues you follow. Unread only by default; + `--all` includes already-read, `--limit N` bounds the fetch, `--json` for raw + output. Backed by the GraphQL `notifications` query — no browser needed. + ## [0.11.1] - 2026-07-17 ### Documentation diff --git a/linear b/linear index 96da376..fabbdf9 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.11.1" +__version__ = "0.12.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 @@ -2756,6 +2756,73 @@ def cmd_states(args, cfg, api_key, team_id): print(f" {name:20s} {meta['type']}") +_INBOX_TYPE_LABEL = { + "issueNewComment": "comment", + "issueCommentMention": "mention", + "issueMention": "mention", + "issueCommentReaction": "reaction", + "issueAssignedToYou": "assigned", + "issueUnassignedFromYou": "unassigned", + "issueStatusChanged": "status", + "issueDue": "due", + "issueBlocking": "blocking", + "issueEmojiReaction": "reaction", + "issueSubscribed": "subscribed", +} + + +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.""" + limit = getattr(args, "limit", None) or 30 + data = gql(api_key, """ + query($n: Int!) { + notifications(first: $n) { + nodes { + id __typename createdAt readAt + ... on IssueNotification { + type + issue { identifier title url state { name } } + comment { body } + actor { name displayName } + } + } + } + } + """, {"n": limit}) + if check_errors(data): + return + nodes = ((data.get("data") or {}).get("notifications") or {}).get("nodes") or [] + # Only issue notifications carry an issue; ignore project/other kinds for now. + items = [n for n in nodes if n.get("issue")] + if not getattr(args, "all", False): + items = [n for n in items if not n.get("readAt")] + + if getattr(args, "json", False): + print(json.dumps(items, indent=2)) + return + + if not items: + hint = "" if getattr(args, "all", False) else " (no unread — use --all to include read)" + print(f"Inbox empty{hint}.") + return + + unread = sum(1 for n in items if not n.get("readAt")) + scope = "" if getattr(args, "all", False) else " unread" + print(f"Inbox — {len(items)}{scope} ({unread} unread):") + for n in items: + iss = n.get("issue") or {} + actor = n.get("actor") or {} + who = actor.get("name") or actor.get("displayName") or "-" + label = _INBOX_TYPE_LABEL.get(n.get("type"), n.get("type") or "note") + dot = " " if n.get("readAt") else "*" + title = (iss.get("title") or "")[:48] + print(f" {dot} {iss.get('identifier','-'):11s}{label:11s}{who:14.14s}{title}") + body = ((n.get("comment") or {}).get("body") or "").replace("\n", " ").strip() + if body and label in ("comment", "mention"): + print(f' "{body[:96]}"') + + def cmd_agents(args, cfg, api_key, team_id): """List the workspace's agent members — the app users you can `--delegate` to. @@ -2985,6 +3052,14 @@ def main(): p_states = sub.add_parser("states", help="List the team's workflow states") 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.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") + # Back-compat + ergonomics: `projects ` (a bare name, not a verb) is # shorthand for `projects show `. Insert the implicit `show` so the # detail view keeps working alongside the create/archive/delete subcommands. @@ -3049,6 +3124,8 @@ def main(): cmd_agents(args, cfg, api_key, team_id) elif args.command == "states": cmd_states(args, cfg, api_key, team_id) + elif args.command == "inbox": + cmd_inbox(args, cfg, api_key, team_id) if __name__ == "__main__": diff --git a/test_linear.py b/test_linear.py index 6723c97..1ad6046 100644 --- a/test_linear.py +++ b/test_linear.py @@ -46,6 +46,22 @@ def test_empty_name_falls_back_to_number(self): self.assertEqual(linear_cli.cycle_label({"name": "", "number": 7}), "Cycle 7") +class InboxTypeLabelTest(unittest.TestCase): + def test_common_notification_types_have_friendly_labels(self): + # The `notifications` query returns these; each must render a human label, + # not the raw camelCase type, in `linear inbox`. + for raw, expected in [ + ("issueNewComment", "comment"), + ("issueCommentMention", "mention"), + ("issueAssignedToYou", "assigned"), + ("issueStatusChanged", "status"), + ]: + self.assertEqual(linear_cli._INBOX_TYPE_LABEL.get(raw), expected) + + def test_unmapped_type_absent_so_cmd_inbox_falls_back_to_raw(self): + self.assertNotIn("issueSomeFutureKind", linear_cli._INBOX_TYPE_LABEL) + + class BulkUpdateTest(unittest.TestCase): def test_collect_update_identifiers_dedupes_positional_and_stdin_in_order(self): self.assertEqual( From 997b338c9078109975e514c7484acf5889818e9c Mon Sep 17 00:00:00 2001 From: Muqsit Date: Sat, 18 Jul 2026 18:46:21 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(inbox):=20address=20prix=20review=20?= =?UTF-8?q?=E2=80=94=20truncate=20label=20column,=20honor=20--limit,=20add?= =?UTF-8?q?=20cmd=5Finbox=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. label column now uses {:11.11s} so an unmapped notification type is truncated instead of overflowing and misaligning every following row. 2. --limit no longer silently coerces 0 to 30; explicit <1 gets a clear error. 3. Add cmd_inbox stdout test (monkeypatched gql) asserting a mapped + unmapped type stay column-aligned — catches the alignment bug directly. --- linear | 7 +++++-- test_linear.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/linear b/linear index fabbdf9..1167c40 100755 --- a/linear +++ b/linear @@ -2774,7 +2774,10 @@ _INBOX_TYPE_LABEL = { 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.""" - limit = getattr(args, "limit", None) or 30 + 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) + return data = gql(api_key, """ query($n: Int!) { notifications(first: $n) { @@ -2817,7 +2820,7 @@ def cmd_inbox(args, cfg, api_key, team_id): label = _INBOX_TYPE_LABEL.get(n.get("type"), n.get("type") or "note") dot = " " if n.get("readAt") else "*" title = (iss.get("title") or "")[:48] - print(f" {dot} {iss.get('identifier','-'):11s}{label:11s}{who:14.14s}{title}") + print(f" {dot} {iss.get('identifier','-'):11.11s}{label:11.11s}{who:14.14s}{title}") body = ((n.get("comment") or {}).get("body") or "").replace("\n", " ").strip() if body and label in ("comment", "mention"): print(f' "{body[:96]}"') diff --git a/test_linear.py b/test_linear.py index 1ad6046..2b96e27 100644 --- a/test_linear.py +++ b/test_linear.py @@ -61,6 +61,39 @@ def test_common_notification_types_have_friendly_labels(self): def test_unmapped_type_absent_so_cmd_inbox_falls_back_to_raw(self): self.assertNotIn("issueSomeFutureKind", linear_cli._INBOX_TYPE_LABEL) + def test_cmd_inbox_truncates_unmapped_type_and_keeps_rows_aligned(self): + # A notification type not in _INBOX_TYPE_LABEL must not break column + # alignment: the label column is fixed-width, so the actor column lands + # at the same offset on a mapped row and an unmapped (long-type) row. + def fake_gql(_key, _query, _vars=None): + node = lambda ident, typ: { + "id": ident, "__typename": "IssueNotification", + "createdAt": "2026-07-18T00:00:00Z", "readAt": None, "type": typ, + "issue": {"identifier": ident, "title": "T", "url": "u", "state": {"name": "Todo"}}, + "comment": None, "actor": {"name": "Bisma"}, + } + return {"data": {"notifications": {"nodes": [ + node("RUSH-1", "issueNewComment"), + node("RUSH-2", "issueSomeVeryLongFutureKind"), + ]}}} + + args = types.SimpleNamespace(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 + + out = stdout.getvalue() + self.assertIn("comment", out) # mapped type renders its friendly label + self.assertNotIn("issueSomeVeryLongFutureKind", out) # long raw type truncated + actor_rows = [ln for ln in out.splitlines() if "Bisma" in ln] + self.assertEqual(len(actor_rows), 2) + self.assertEqual(len({ln.index("Bisma") for ln in actor_rows}), 1) # aligned + class BulkUpdateTest(unittest.TestCase): def test_collect_update_identifiers_dedupes_positional_and_stdin_in_order(self):