From f2349edb4c27370c6085361623a9e47a320f3de0 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:14:10 +0100 Subject: [PATCH] Keep a Linear sync going when one issue or document cannot be read An issue deleted after it was listed, or comments the token cannot read, now lose that detail with a warning instead of failing the whole sync; an unreadable document is skipped. --- docsgpt/parser/remote/linear_loader.py | 31 ++++++++++++++----- tests/parser/remote/test_linear_loader.py | 37 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docsgpt/parser/remote/linear_loader.py b/docsgpt/parser/remote/linear_loader.py index c768112f..3d40eb07 100644 --- a/docsgpt/parser/remote/linear_loader.py +++ b/docsgpt/parser/remote/linear_loader.py @@ -19,7 +19,7 @@ import re from typing import Any, Optional from docsgpt.connectors import linear -from docsgpt.connectors.mcp import run_connection_session +from docsgpt.connectors.mcp import MCPToolError, run_connection_session from docsgpt.parser.remote.base import BaseRemote from docsgpt.parser.schema.base import Document @@ -90,9 +90,19 @@ class LinearLoader(BaseRemote): issues.setdefault(key, issue) documents = [] for key, issue in issues.items(): + # One issue Linear will not read (deleted since it was listed, or + # out of the token's reach) loses that detail, not the whole sync. if linear.is_truncated(issue.get("description")) or issue.get("descriptionTruncated"): - issue = await self._full_issue(session, key, issue) - comments = await self._comments(session, key) if selection["include_comments"] else [] + try: + issue = await self._full_issue(session, key, issue) + except MCPToolError as exc: + logger.warning("Linear sync keeps %s as listed: %s", key, exc) + comments = [] + if selection["include_comments"]: + try: + comments = await self._comments(session, key) + except MCPToolError as exc: + logger.warning("Linear sync leaves out the comments of %s: %s", key, exc) documents.append(self._issue_document(issue, comments, selection)) if selection["include_documents"]: documents.extend(await self._project_documents(session, selection["projects"])) @@ -179,11 +189,16 @@ class LinearLoader(BaseRemote): ): content = record.get("content") if (not content or linear.is_truncated(content)) and get_schema is not None and record.get("id"): - full = linear.unwrap( - await session.call("get_document", {linear.argument(get_schema, "id", "documentId"): - str(record["id"])}), - "document", - ) + try: + full = linear.unwrap( + await session.call("get_document", {linear.argument(get_schema, "id", "documentId"): + str(record["id"])}), + "document", + ) + except MCPToolError as exc: + # A document that cannot be read is skipped, not the sync. + logger.warning("Linear sync skips document %s: %s", record.get("id"), exc) + continue record = {**record, **{k: v for k, v in full.items() if v not in (None, "")}} document = self._linear_document(record, project) path = document.extra_info["file_path"] diff --git a/tests/parser/remote/test_linear_loader.py b/tests/parser/remote/test_linear_loader.py index 8527cbf1..a8b6e750 100644 --- a/tests/parser/remote/test_linear_loader.py +++ b/tests/parser/remote/test_linear_loader.py @@ -288,3 +288,40 @@ class TestLoadData: def test_needs_a_connection(self): with pytest.raises(ValueError): LinearLoader().load_data({"teams": ["t1"]}) + + +class TestOneBadItemDoesNotStopTheSync: + """An issue deleted since it was listed, or comments the token cannot + read, lose that detail; the rest of the sync still lands.""" + + @staticmethod + def _refuse(args): + from docsgpt.connectors.mcp import MCPToolError + + raise MCPToolError("Entity not found") + + def test_an_issue_that_cannot_be_read_in_full_keeps_what_was_listed(self): + clipped = {**ISSUE, "description": "The spinner (truncated, use get_issue to read the full description)"} + session = FakeLinear({ + "list_issues": lambda args: {"issues": [clipped, {**ISSUE, "id": "ENG-2", "title": "Other"}]}, + "get_issue": self._refuse, + }) + docs = _collect(session, teams=["team-1"], include_comments=False) + assert [d.text.splitlines()[0] for d in docs] == ["# ENG-1310: Fix the loader", "# ENG-2: Other"] + + def test_comments_that_cannot_be_read_are_left_out(self): + session = FakeLinear({"list_issues": lambda args: [ISSUE], "list_comments": self._refuse}) + [doc] = _collect(session, teams=["team-1"]) + assert "## Comments" not in doc.text + + def test_a_document_that_cannot_be_read_is_skipped(self): + session = FakeLinear({ + "list_issues": lambda args: {"issues": []}, + "list_documents": lambda args: {"documents": [ + {"id": "d1", "title": "Gone"}, + {"id": "d2", "title": "Plan", "content": "We ship on Monday."}, + ]}, + "get_document": self._refuse, + }) + docs = _collect(session, projects=[{"id": "project-1", "name": "Acme"}], include_documents=True) + assert [d.text.splitlines()[0] for d in docs] == ["# Plan"]