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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 81 additions & 1 deletion linear
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2756,6 +2756,76 @@ 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", 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) {
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','-'):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]}"')


def cmd_agents(args, cfg, api_key, team_id):
"""List the workspace's agent members — the app users you can `--delegate` to.

Expand Down Expand Up @@ -2985,6 +3055,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 <name>` (a bare name, not a verb) is
# shorthand for `projects show <name>`. Insert the implicit `show` so the
# detail view keeps working alongside the create/archive/delete subcommands.
Expand Down Expand Up @@ -3049,6 +3127,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__":
Expand Down
49 changes: 49 additions & 0 deletions test_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,55 @@ 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)

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):
self.assertEqual(
Expand Down
Loading