Sync Linear issues and documents into Knowledge with the Linear sign-in

The Linear connector now syncs as well as giving agents its tools, from
one connection. Linear's MCP server is its own OAuth issuer, so its
tokens are read through the same MCP tools the agents use (list_issues,
get_issue, list_comments, list_documents) rather than Linear's GraphQL
API, and no OAuth app has to be registered.

A source picks teams and projects, with comments (on by default) and the
projects' documents. Each issue becomes one document with its state,
assignee, priority, labels, description and comments, filed under its
team and citing its Linear URL. Each sync reads up to 500 issues and 100
documents again. /api/connections/<id>/linear lists the teams and
projects to pick from. Sources sync on their schedule with the owner's
connection, and pause when the sign-in needs reconnecting.
This commit is contained in:
arc53-machine committed 2026-09-29 11:32:40 +01:00
1 parent 805e65eeb4
commit 03d1b10897
9 files changed
+1168 -16

No files matched your search

+47 -5
View File
@@ -326,6 +326,14 @@ def _start_sync(user_id: str, row: dict, sync: dict):
return ("Pick a GitHub repository", 400)
items = {**items, "repo_url": repo}
name = name or repo
elif definition.sync_ingestor == "linear":
from docsgpt.connectors import linear
try:
items = linear.normalize_selection(items)
except ValueError as err:
return (str(err), 400)
name = name or linear.selection_name(items)
name = name or definition.name
# Validate before claiming the idempotency key: a rejected request must
# leave the key free for the corrected retry.
@@ -346,11 +354,15 @@ def _start_sync(user_id: str, row: dict, sync: dict):
"sync_frequency": frequency,
}
else:
fields = {f.key for f in definition.setup_fields}
source_data = {k: v for k, v in items.items() if k in fields and v not in (None, "")}
missing = [f.label for f in definition.setup_fields if f.required and f.key not in source_data]
if missing:
return (f"Missing: {', '.join(missing)}", 400)
if definition.sync_ingestor == "linear":
# The teams and projects picked, read with the connection's MCP sign-in.
source_data = items
else:
fields = {f.key for f in definition.setup_fields}
source_data = {k: v for k, v in items.items() if k in fields and v not in (None, "")}
missing = [f.label for f in definition.setup_fields if f.required and f.key not in source_data]
if missing:
return (f"Missing: {', '.join(missing)}", 400)
task_fn = ingest_remote
kwargs = {
"source_data": source_data,
@@ -418,6 +430,36 @@ class ConnectionRepositories(Resource):
)
@connections_ns.route("/connections/<string:connection_id>/linear")
class LinearWorkspace(Resource):
@api.doc(
description=(
"Linear: the teams and projects the connection can see, for the sync picker. "
"Read through Linear's MCP server with the connection's sign-in."
)
)
def get(self, connection_id: str):
from docsgpt.connectors import linear, mcp
user_id = _user_id()
if not user_id:
return _unauthorized()
with db_readonly() as conn:
row = _owned(conn, connection_id, user_id)
if row is None or catalog.connector_key_for_row(row) != linear.LINEAR_CONNECTOR:
return _not_found()
try:
workspace = mcp.run_connection_session(row, linear.mcp_url(), linear.list_workspace)
except service.ConnectionUnavailable:
return _error("Reconnect to continue", 409, code="reconnect")
except service.TransientConnectionError:
return _error("Linear is not responding. Try again.", 503)
except Exception as err:
current_app.logger.error(f"Error listing Linear teams: {err}", exc_info=True)
return _error("Failed to list Linear teams", 502)
return make_response(jsonify({"success": True, **workspace}), 200)
@connections_ns.route("/connections/<string:connection_id>/reconnect")
class ConnectionReconnect(Resource):
@api.doc(