chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
__all__ = []
+330
View File
@@ -0,0 +1,330 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import aiohttp
from mirage.resource.linear.config import LinearConfig
from mirage.resource.secrets import reveal_secret
from mirage.core.linear.queries import ( # isort: skip
COMMENT_CREATE_MUTATION, COMMENT_UPDATE_MUTATION, ISSUE_COMMENTS_QUERY,
ISSUE_CREATE_MUTATION, ISSUE_LOOKUP_QUERY, ISSUE_QUERY, ISSUE_SEARCH_QUERY,
ISSUE_UPDATE_MUTATION, TEAM_CYCLES_QUERY, TEAM_ISSUES_QUERY,
TEAM_LIST_QUERY, TEAM_MEMBERS_QUERY, TEAM_PROJECTS_QUERY,
USER_LOOKUP_QUERY)
class LinearAPIError(RuntimeError):
def __init__(
self,
message: str,
*,
errors: list[dict] | None = None,
status: int | None = None,
) -> None:
super().__init__(message)
self.errors = errors or []
self.status = status
def linear_headers(config: LinearConfig) -> dict[str, str]:
return {
"Authorization": reveal_secret(config.api_key),
"Content-Type": "application/json",
}
async def graphql_request(
config: LinearConfig,
query: str,
variables: dict | None = None,
) -> dict:
payload = {
"query": query,
"variables": variables or {},
}
async with aiohttp.ClientSession() as session:
async with session.post(
config.base_url,
headers=linear_headers(config),
json=payload,
) as resp:
data = await resp.json()
if resp.status >= 400:
errors = data.get("errors") if isinstance(data, dict) else None
message = _error_message(
errors) or f"Linear API error: HTTP {resp.status}"
raise LinearAPIError(message,
errors=errors,
status=resp.status)
if data.get("errors"):
message = _error_message(data["errors"]) or "Linear API error"
raise LinearAPIError(message, errors=data["errors"])
return data["data"]
def _error_message(errors: list[dict] | None) -> str | None:
if not errors:
return None
first = errors[0]
if isinstance(first, dict):
msg = first.get("message")
if isinstance(msg, str):
return msg
return None
async def paginate_connection(
config: LinearConfig,
query: str,
variables: dict | None,
path: tuple[str, ...],
) -> list[dict]:
merged_vars = dict(variables or {})
merged_vars.setdefault("first", 50)
merged_vars["after"] = None
nodes: list[dict] = []
while True:
data = await graphql_request(config, query, merged_vars)
cursor = data
for key in path:
cursor = cursor[key]
nodes.extend(cursor["nodes"])
page_info = cursor["pageInfo"]
if not page_info.get("hasNextPage"):
break
merged_vars["after"] = page_info.get("endCursor")
return nodes
async def list_teams(config: LinearConfig) -> list[dict]:
return await paginate_connection(config, TEAM_LIST_QUERY, None,
("teams", ))
async def list_team_members(config: LinearConfig, team_id: str) -> list[dict]:
return await paginate_connection(
config,
TEAM_MEMBERS_QUERY,
{"teamId": team_id},
("team", "members"),
)
async def list_team_issues(config: LinearConfig, team_id: str) -> list[dict]:
return await paginate_connection(
config,
TEAM_ISSUES_QUERY,
{"teamId": team_id},
("team", "issues"),
)
async def list_team_projects(config: LinearConfig, team_id: str) -> list[dict]:
return await paginate_connection(
config,
TEAM_PROJECTS_QUERY,
{"teamId": team_id},
("team", "projects"),
)
async def list_team_cycles(config: LinearConfig, team_id: str) -> list[dict]:
return await paginate_connection(
config,
TEAM_CYCLES_QUERY,
{"teamId": team_id},
("team", "cycles"),
)
async def get_issue(config: LinearConfig, issue_id: str) -> dict:
data = await graphql_request(config, ISSUE_QUERY, {"issueId": issue_id})
return data["issue"]
async def list_issue_comments(config: LinearConfig,
issue_id: str) -> list[dict]:
return await paginate_connection(
config,
ISSUE_COMMENTS_QUERY,
{"issueId": issue_id},
("issue", "comments"),
)
async def resolve_issue_id(
config: LinearConfig,
issue_id: str | None = None,
issue_key: str | None = None,
) -> str:
if issue_id:
return issue_id
if not issue_key:
raise ValueError("issue id or issue key is required")
team_key, _, number_str = issue_key.partition("-")
if not team_key or not number_str.isdigit():
raise ValueError(f"invalid issue key: {issue_key}")
data = await graphql_request(
config,
ISSUE_LOOKUP_QUERY,
{
"teamKey": team_key,
"number": float(number_str),
},
)
nodes = data["issues"]["nodes"]
if not nodes:
raise FileNotFoundError(issue_key)
return nodes[0]["id"]
async def resolve_user_id(
config: LinearConfig,
assignee_id: str | None = None,
assignee_email: str | None = None,
) -> str:
if assignee_id:
return assignee_id
if not assignee_email:
raise ValueError("assignee id or assignee email is required")
data = await graphql_request(config, USER_LOOKUP_QUERY,
{"email": assignee_email})
nodes = data["users"]["nodes"]
if not nodes:
raise FileNotFoundError(assignee_email)
return nodes[0]["id"]
async def issue_create(
config: LinearConfig,
*,
team_id: str,
title: str,
description: str | None,
) -> dict:
input_payload: dict[str, object] = {"title": title, "teamId": team_id}
if description:
input_payload["description"] = description
data = await graphql_request(
config,
ISSUE_CREATE_MUTATION,
{"input": input_payload},
)
issue = data["issueCreate"]["issue"]
return await get_issue(config, issue["id"])
async def issue_update(
config: LinearConfig,
*,
issue_id: str,
title: str | None,
description: str | None,
state_id: str | None = None,
assignee_id: str | None = None,
priority: int | None = None,
project_id: str | None = None,
label_ids: list[str] | None = None,
) -> dict:
payload: dict[str, object] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if state_id is not None:
payload["stateId"] = state_id
if assignee_id is not None:
payload["assigneeId"] = assignee_id
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["projectId"] = project_id
if label_ids is not None:
payload["labelIds"] = label_ids
if not payload:
raise ValueError("no updates provided")
await graphql_request(
config,
ISSUE_UPDATE_MUTATION,
{
"id": issue_id,
"input": payload,
},
)
return await get_issue(config, issue_id)
async def comment_create(
config: LinearConfig,
*,
issue_id: str,
body: str,
) -> dict:
await graphql_request(
config,
COMMENT_CREATE_MUTATION,
{"input": {
"issueId": issue_id,
"body": body
}},
)
comments = await list_issue_comments(config, issue_id)
if not comments:
raise RuntimeError("comment was created but no comments were returned")
return comments[-1]
async def comment_update(
config: LinearConfig,
*,
comment_id: str,
body: str,
) -> dict:
data = await graphql_request(
config,
COMMENT_UPDATE_MUTATION,
{
"id": comment_id,
"input": {
"body": body
}
},
)
comment = data["commentUpdate"]["comment"]
issue = comment.get("issue") or {}
issue_id = issue.get("id")
if issue_id:
comments = await list_issue_comments(config, issue_id)
for item in comments:
if item.get("id") == comment_id:
return item
return comment
async def search_issues(
config: LinearConfig,
query: str,
limit: int = 50,
) -> list[dict]:
data = await graphql_request(
config,
ISSUE_SEARCH_QUERY,
{
"term": query,
"first": limit
},
)
return data.get("searchIssues", {}).get("nodes", [])
+29
View File
@@ -0,0 +1,29 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.linear import LinearAccessor
from mirage.cache.index import IndexCacheStore
from mirage.commands.builtin.constants import SCOPE_ERROR
from mirage.core.linear.readdir import readdir
from mirage.types import PathSpec
from mirage.utils.glob_walk import resolve_glob_with
async def resolve_glob(
accessor: LinearAccessor,
paths: list[PathSpec],
index: IndexCacheStore | None = None,
) -> list[PathSpec]:
return await resolve_glob_with(readdir, accessor, paths, index,
SCOPE_ERROR)
+155
View File
@@ -0,0 +1,155 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
def normalize_team(team: dict) -> dict:
states = []
for state in (team.get("states") or {}).get("nodes", []):
states.append({
"state_id": state.get("id"),
"state_name": state.get("name"),
"type": state.get("type"),
})
return {
"team_id": team.get("id"),
"team_key": team.get("key"),
"team_name": team.get("name"),
"name": team.get("name"),
"description": team.get("description"),
"timezone": team.get("timezone"),
"updated_at": team.get("updatedAt"),
"states": states,
}
def normalize_user(user: dict) -> dict:
return {
"user_id": user.get("id"),
"name": user.get("name"),
"display_name": user.get("displayName") or user.get("name"),
"email": user.get("email"),
"is_active": user.get("active"),
"is_admin": user.get("admin"),
"updated_at": user.get("updatedAt"),
"url": user.get("url"),
}
def normalize_issue(issue: dict) -> dict:
team = issue.get("team") or {}
state = issue.get("state") or {}
project = issue.get("project") or {}
cycle = issue.get("cycle") or {}
assignee = issue.get("assignee") or {}
creator = issue.get("creator") or {}
labels = (issue.get("labels") or {}).get("nodes", [])
return {
"issue_id": issue.get("id"),
"issue_key": issue.get("identifier"),
"title": issue.get("title"),
"description": issue.get("description") or "",
"team_id": team.get("id"),
"team_key": team.get("key"),
"team_name": team.get("name"),
"project_id": project.get("id"),
"project_name": project.get("name"),
"cycle_id": cycle.get("id"),
"cycle_name": cycle.get("name"),
"cycle_number": cycle.get("number"),
"state_id": state.get("id"),
"state_name": state.get("name"),
"assignee_id": assignee.get("id"),
"assignee_email": assignee.get("email"),
"assignee_name": assignee.get("name"),
"creator_id": creator.get("id"),
"creator_email": creator.get("email"),
"creator_name": creator.get("name"),
"priority": issue.get("priority"),
"label_ids": [label.get("id") for label in labels],
"label_names": [label.get("name") for label in labels],
"created_at": issue.get("createdAt"),
"updated_at": issue.get("updatedAt"),
"url": issue.get("url"),
}
def normalize_comment(comment: dict, *, issue_id: str,
issue_key: str | None) -> dict:
user = comment.get("user") or {}
return {
"comment_id": comment.get("id"),
"issue_id": issue_id,
"issue_key": issue_key,
"user_id": user.get("id"),
"user_email": user.get("email"),
"user_name": user.get("displayName") or user.get("name"),
"body": comment.get("body") or "",
"created_at": comment.get("createdAt"),
"updated_at": comment.get("updatedAt"),
"url": comment.get("url"),
}
def normalize_project(
project: dict,
*,
team_id: str,
team_key: str | None,
team_name: str | None,
issues: list[dict],
) -> dict:
lead = project.get("lead") or {}
return {
"project_id": project.get("id"),
"team_id": team_id,
"team_key": team_key,
"team_name": team_name,
"name": project.get("name"),
"description": project.get("description"),
"state": (project.get("status") or {}).get("type"),
"lead_id": lead.get("id"),
"updated_at": project.get("updatedAt"),
"url": project.get("url"),
"issue_count": len(issues),
"issues": issues,
}
def normalize_cycle(cycle: dict, *, team_id: str) -> dict:
return {
"cycle_id": cycle.get("id"),
"team_id": team_id,
"name": cycle.get("name"),
"number": cycle.get("number"),
"starts_at": cycle.get("startsAt"),
"ends_at": cycle.get("endsAt"),
"updated_at": cycle.get("updatedAt"),
"url": cycle.get("url"),
}
def to_json_bytes(value: dict | list) -> bytes:
return json.dumps(value, ensure_ascii=False, indent=2).encode()
def to_jsonl_bytes(rows: list[dict]) -> bytes:
ordered = sorted(rows, key=lambda row: row.get("created_at") or "")
if not ordered:
return b""
text = "\n".join(
json.dumps(row, ensure_ascii=False, separators=(",", ":"))
for row in ordered)
return text.encode() + b"\n"
+53
View File
@@ -0,0 +1,53 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.utils.naming import parse_id_name
from mirage.utils.sanitize import sanitize_name
split_suffix_id = parse_id_name
def team_dirname(team: dict) -> str:
parts: list[str] = []
if team.get("key"):
parts.append(sanitize_name(team["key"]))
if team.get("name"):
sanitized_name = sanitize_name(team["name"])
if sanitized_name not in parts:
parts.append(sanitized_name)
if not parts:
parts.append("team")
return f"{'__'.join(parts)}__{team['id']}"
def member_filename(user: dict) -> str:
label = sanitize_name(
user.get("displayName") or user.get("name") or user.get("email")
or "user")
return f"{label}__{user['id']}.json"
def issue_dirname(issue: dict) -> str:
key = issue.get("identifier") or issue.get("id") or "issue"
return f"{sanitize_name(key)}__{issue['id']}"
def project_filename(project: dict) -> str:
label = sanitize_name(project.get("name") or "project")
return f"{label}__{project['id']}.json"
def cycle_filename(cycle: dict) -> str:
label = sanitize_name(cycle.get("name") or "cycle")
return f"{label}__{cycle['id']}.json"
+339
View File
@@ -0,0 +1,339 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
TEAM_LIST_QUERY = """
query Teams($first: Int!, $after: String) {
teams(first: $first, after: $after) {
nodes {
id
key
name
description
timezone
updatedAt
states {
nodes {
id
name
type
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
TEAM_MEMBERS_QUERY = """
query TeamMembers($teamId: String!, $first: Int!, $after: String) {
team(id: $teamId) {
members(first: $first, after: $after) {
nodes {
id
name
displayName
email
active
admin
url
updatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"""
TEAM_ISSUES_QUERY = """
query TeamIssues($teamId: String!, $first: Int!, $after: String) {
team(id: $teamId) {
issues(first: $first, after: $after) {
nodes {
id
identifier
title
description
priority
url
createdAt
updatedAt
team {
id
key
name
}
state {
id
name
}
project {
id
name
}
cycle {
id
name
number
}
assignee {
id
name
email
}
creator {
id
name
email
}
labels {
nodes {
id
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"""
TEAM_PROJECTS_QUERY = """
query TeamProjects($teamId: String!, $first: Int!, $after: String) {
team(id: $teamId) {
projects(first: $first, after: $after) {
nodes {
id
name
description
status {
type
}
url
updatedAt
lead {
id
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"""
TEAM_CYCLES_QUERY = """
query TeamCycles($teamId: String!, $first: Int!, $after: String) {
team(id: $teamId) {
cycles(first: $first, after: $after) {
nodes {
id
name
number
startsAt
endsAt
updatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"""
ISSUE_QUERY = """
query Issue($issueId: String!) {
issue(id: $issueId) {
id
identifier
title
description
priority
url
createdAt
updatedAt
team {
id
key
name
}
state {
id
name
}
project {
id
name
}
cycle {
id
name
number
}
assignee {
id
name
email
}
creator {
id
name
email
}
labels {
nodes {
id
name
}
}
}
}
"""
ISSUE_COMMENTS_QUERY = """
query IssueComments($issueId: String!, $first: Int!, $after: String) {
issue(id: $issueId) {
comments(first: $first, after: $after) {
nodes {
id
body
url
createdAt
updatedAt
user {
id
name
displayName
email
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
"""
ISSUE_LOOKUP_QUERY = """
query IssueLookup($teamKey: String!, $number: Float!) {
issues(
filter: {
team: { key: { eq: $teamKey } }
number: { eq: $number }
}
first: 1
) {
nodes {
id
identifier
}
}
}
"""
USER_LOOKUP_QUERY = """
query UserLookup($email: String!) {
users(filter: { email: { eq: $email } }, first: 1) {
nodes {
id
email
name
}
}
}
"""
ISSUE_CREATE_MUTATION = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
}
}
}
"""
ISSUE_UPDATE_MUTATION = """
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
identifier
}
}
}
"""
COMMENT_CREATE_MUTATION = """
mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
issue {
id
identifier
}
}
}
}
"""
COMMENT_UPDATE_MUTATION = """
mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
commentUpdate(id: $id, input: $input) {
success
comment {
id
issue {
id
identifier
}
}
}
}
"""
ISSUE_SEARCH_QUERY = """
query IssueSearch($term: String!, $first: Int) {
searchIssues(term: $term, first: $first) {
nodes {
id
identifier
title
state { id name }
assignee { id displayName email }
url
}
}
}
"""
+134
View File
@@ -0,0 +1,134 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.linear import LinearAccessor
from mirage.cache.index import IndexCacheStore
from mirage.core.linear._client import (get_issue, list_issue_comments,
list_team_cycles, list_team_issues,
list_team_members, list_team_projects,
list_teams)
from mirage.core.linear.normalize import (normalize_comment, normalize_cycle,
normalize_issue, normalize_project,
normalize_team, normalize_user,
to_json_bytes, to_jsonl_bytes)
from mirage.core.linear.pathing import split_suffix_id
from mirage.resource.linear.config import LinearConfig
from mirage.types import PathSpec
from mirage.utils.errors import enoent
async def read_bytes(
config: LinearConfig,
path: PathSpec,
virtual: str,
) -> bytes:
key = path.strip("/")
parts = key.split("/")
if len(parts) == 3 and parts[0] == "teams" and parts[2] == "team.json":
_, team_id = split_suffix_id(parts[1])
teams = await list_teams(config)
if config.team_ids:
teams = [
team for team in teams if team.get("id") in config.team_ids
]
for team in teams:
if team.get("id") == team_id:
return to_json_bytes(normalize_team(team))
raise enoent(virtual)
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "members":
_, team_id = split_suffix_id(parts[1])
_, user_id = split_suffix_id(parts[3], suffix=".json")
users = await list_team_members(config, team_id)
for user in users:
if user.get("id") == user_id:
return to_json_bytes(normalize_user(user))
raise enoent(virtual)
if len(parts) == 5 and parts[0] == "teams" and parts[2] == "issues":
_, issue_id = split_suffix_id(parts[3])
issue = await get_issue(config, issue_id)
if parts[4] == "issue.json":
return to_json_bytes(normalize_issue(issue))
if parts[4] == "comments.jsonl":
norm_issue = normalize_issue(issue)
comments = await list_issue_comments(config, issue_id)
rows = [
normalize_comment(comment,
issue_id=issue_id,
issue_key=norm_issue.get("issue_key"))
for comment in comments
]
return to_jsonl_bytes(rows)
raise enoent(virtual)
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "projects":
_, team_id = split_suffix_id(parts[1])
_, project_id = split_suffix_id(parts[3], suffix=".json")
teams = await list_teams(config)
team = next((item for item in teams if item.get("id") == team_id), {})
projects = await list_team_projects(config, team_id)
team_issues = await list_team_issues(config, team_id)
for project in projects:
if project.get("id") == project_id:
project_issues = []
for issue in team_issues:
if (issue.get("project") or {}).get("id") != project_id:
continue
state = issue.get("state") or {}
project_issues.append({
"issue_id": issue.get("id"),
"issue_key": issue.get("identifier"),
"title": issue.get("title"),
"state_id": state.get("id"),
"state_name": state.get("name"),
"url": issue.get("url"),
})
return to_json_bytes(
normalize_project(
project,
team_id=team_id,
team_key=team.get("key"),
team_name=team.get("name"),
issues=project_issues,
))
raise enoent(virtual)
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "cycles":
_, team_id = split_suffix_id(parts[1])
_, cycle_id = split_suffix_id(parts[3], suffix=".json")
cycles = await list_team_cycles(config, team_id)
for cycle in cycles:
if cycle.get("id") == cycle_id:
return to_json_bytes(normalize_cycle(cycle, team_id=team_id))
raise enoent(virtual)
raise enoent(virtual)
async def read(
accessor: LinearAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> bytes:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
if isinstance(path, PathSpec):
path = path.mount_path
return await read_bytes(accessor.config, path, virtual)
+267
View File
@@ -0,0 +1,267 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.linear import LinearAccessor
from mirage.cache.index import IndexCacheStore, IndexEntry
from mirage.core.linear._client import (list_team_cycles, list_team_issues,
list_team_members, list_team_projects,
list_teams)
from mirage.core.linear.pathing import (cycle_filename, issue_dirname,
member_filename, project_filename,
team_dirname)
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
VIRTUAL_ROOTS = ("teams", )
async def readdir(
accessor: LinearAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> list[str]:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
path = (path.dir if path.pattern else path).mount_path
key = path.strip("/")
idx_key = "/" + key if key else "/"
if not key:
return [f"{prefix}/teams"]
if key == "teams":
if index is not None:
listing = await index.list_dir(idx_key)
if listing.entries is not None:
return [f"{prefix}{entry}" for entry in listing.entries]
teams = await list_teams(accessor.config)
if accessor.config.team_ids:
teams = [
team for team in teams
if team.get("id") in accessor.config.team_ids
]
entries = []
for team in teams:
dirname = team_dirname(team)
entry = IndexEntry(
id=team["id"],
name=team.get("name") or team.get("key") or team["id"],
resource_type="linear/team",
remote_time=team.get("updatedAt") or "",
vfs_name=dirname,
)
entries.append((dirname, entry))
if index is not None:
await index.set_dir(idx_key, entries)
return [f"{prefix}/teams/{name}" for name, _ in entries]
parts = key.split("/")
if len(parts) == 2 and parts[0] == "teams":
if index is not None:
result = await index.get(idx_key)
if result.entry is None:
# Auto-bootstrap: populate teams index.
parent = PathSpec(
virtual=prefix + "/teams",
directory=prefix + "/teams",
resource_path=mount_key(prefix + "/teams", prefix),
)
await readdir(accessor, parent, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return [
f"{prefix}/{key}/team.json",
f"{prefix}/{key}/members",
f"{prefix}/{key}/issues",
f"{prefix}/{key}/projects",
f"{prefix}/{key}/cycles",
]
if len(parts) == 3 and parts[0] == "teams" and parts[2] == "members":
team_vkey = "/" + "/".join(parts[:2])
if index is not None:
result = await index.get(team_vkey)
if result.entry is None:
# Auto-bootstrap: populate team index.
parent = PathSpec(
virtual=prefix + "/" + "/".join(parts[:2]),
directory=prefix + "/" + "/".join(parts[:2]),
resource_path=mount_key(prefix + "/" + "/".join(parts[:2]),
prefix),
)
await readdir(accessor, parent, index)
result = await index.get(team_vkey)
if result.entry is None:
raise enoent(virtual)
team_id = result.entry.id
listing = await index.list_dir(idx_key)
if listing.entries is not None:
return [f"{prefix}{entry}" for entry in listing.entries]
else:
raise enoent(virtual)
users = await list_team_members(accessor.config, team_id)
entries = []
for user in users:
filename = member_filename(user)
entries.append((
filename,
IndexEntry(
id=user["id"],
name=user.get("name") or user.get("displayName")
or user["id"],
resource_type="linear/user",
remote_time=user.get("updatedAt") or "",
vfs_name=filename,
),
))
await index.set_dir(idx_key, entries)
return [f"{prefix}/{key}/{name}" for name, _ in entries]
if len(parts) == 3 and parts[0] == "teams" and parts[2] == "issues":
team_vkey = "/" + "/".join(parts[:2])
if index is not None:
result = await index.get(team_vkey)
if result.entry is None:
parent = PathSpec(
virtual=prefix + "/" + "/".join(parts[:2]),
directory=prefix + "/" + "/".join(parts[:2]),
resource_path=mount_key(prefix + "/" + "/".join(parts[:2]),
prefix),
)
await readdir(accessor, parent, index)
result = await index.get(team_vkey)
if result.entry is None:
raise enoent(virtual)
team_id = result.entry.id
listing = await index.list_dir(idx_key)
if listing.entries is not None:
return [f"{prefix}{entry}" for entry in listing.entries]
else:
raise enoent(virtual)
issues = await list_team_issues(accessor.config, team_id)
entries = []
for issue in issues:
dirname = issue_dirname(issue)
entries.append((
dirname,
IndexEntry(
id=issue["id"],
name=issue.get("identifier") or issue["id"],
resource_type="linear/issue",
remote_time=issue.get("updatedAt") or "",
vfs_name=dirname,
),
))
await index.set_dir(idx_key, entries)
return [f"{prefix}/{key}/{name}" for name, _ in entries]
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "issues":
if index is not None:
result = await index.get(idx_key)
if result.entry is None:
parent = PathSpec(
virtual=prefix + "/" + "/".join(parts[:3]),
directory=prefix + "/" + "/".join(parts[:3]),
resource_path=mount_key(prefix + "/" + "/".join(parts[:3]),
prefix),
)
await readdir(accessor, parent, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return [f"{prefix}/{key}/issue.json", f"{prefix}/{key}/comments.jsonl"]
if len(parts) == 3 and parts[0] == "teams" and parts[2] == "projects":
team_vkey = "/" + "/".join(parts[:2])
if index is not None:
result = await index.get(team_vkey)
if result.entry is None:
parent = PathSpec(
virtual=prefix + "/" + "/".join(parts[:2]),
directory=prefix + "/" + "/".join(parts[:2]),
resource_path=mount_key(prefix + "/" + "/".join(parts[:2]),
prefix),
)
await readdir(accessor, parent, index)
result = await index.get(team_vkey)
if result.entry is None:
raise enoent(virtual)
team_id = result.entry.id
listing = await index.list_dir(idx_key)
if listing.entries is not None:
return [f"{prefix}{entry}" for entry in listing.entries]
else:
raise enoent(virtual)
projects = await list_team_projects(accessor.config, team_id)
entries = []
for project in projects:
filename = project_filename(project)
entries.append((
filename,
IndexEntry(
id=project["id"],
name=project.get("name") or project["id"],
resource_type="linear/project",
remote_time=project.get("updatedAt") or "",
vfs_name=filename,
),
))
await index.set_dir(idx_key, entries)
return [f"{prefix}/{key}/{name}" for name, _ in entries]
if len(parts) == 3 and parts[0] == "teams" and parts[2] == "cycles":
team_vkey = "/" + "/".join(parts[:2])
if index is not None:
result = await index.get(team_vkey)
if result.entry is None:
parent = PathSpec(
virtual=prefix + "/" + "/".join(parts[:2]),
directory=prefix + "/" + "/".join(parts[:2]),
resource_path=mount_key(prefix + "/" + "/".join(parts[:2]),
prefix),
)
await readdir(accessor, parent, index)
result = await index.get(team_vkey)
if result.entry is None:
raise enoent(virtual)
team_id = result.entry.id
listing = await index.list_dir(idx_key)
if listing.entries is not None:
return [f"{prefix}{entry}" for entry in listing.entries]
else:
raise enoent(virtual)
cycles = await list_team_cycles(accessor.config, team_id)
entries = []
for cycle in cycles:
filename = cycle_filename(cycle)
entries.append((
filename,
IndexEntry(
id=cycle["id"],
name=cycle.get("name") or cycle["id"],
resource_type="linear/cycle",
remote_time=cycle.get("updatedAt") or "",
vfs_name=filename,
),
))
await index.set_dir(idx_key, entries)
return [f"{prefix}/{key}/{name}" for name, _ in entries]
return []
+177
View File
@@ -0,0 +1,177 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.linear import LinearAccessor
from mirage.cache.index import IndexCacheStore
from mirage.core.linear.readdir import readdir as _readdir
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
VIRTUAL_DIRS = {"", "teams"}
async def _populate_via_parent(
accessor: LinearAccessor,
idx_key: str,
prefix: str,
index: IndexCacheStore,
) -> None:
parent_idx = idx_key.rsplit("/", 1)[0] or "/"
parent_path = (prefix + parent_idx) if prefix else parent_idx
try:
await _readdir(
accessor,
PathSpec(virtual=parent_path,
directory=parent_path,
resource_path=mount_key(parent_path, prefix)),
index=index,
)
# best-effort cache populate; canonical ENOENT raised below
except Exception:
pass
async def stat(
accessor: LinearAccessor,
path: PathSpec,
index: IndexCacheStore = None,
) -> FileStat:
if isinstance(path, str):
path = PathSpec(virtual=path,
directory=path,
resource_path=path.strip("/"))
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
key = path.resource_path
idx_key = "/" + key if key else "/"
if key in VIRTUAL_DIRS:
return FileStat(name=key if key else "/", type=FileType.DIRECTORY)
parts = key.split("/")
if len(parts) == 2 and parts[0] == "teams":
if index is None:
raise enoent(virtual)
result = await index.get(idx_key)
if result.entry is None:
await _populate_via_parent(accessor, idx_key, prefix, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return FileStat(
name=result.entry.vfs_name,
type=FileType.DIRECTORY,
modified=result.entry.remote_time or None,
extra={"team_id": result.entry.id},
)
if len(parts) == 3 and parts[0] == "teams" and parts[2] in {
"team.json", "members", "issues", "projects", "cycles"
}:
if parts[2] == "team.json":
team_key = "/" + "/".join(parts[:2])
if index is not None:
result = await index.get(team_key)
team_id = result.entry.id if result.entry else None
else:
team_id = None
return FileStat(
name="team.json",
type=FileType.JSON,
extra={"team_id": team_id},
)
return FileStat(name=parts[2], type=FileType.DIRECTORY)
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "members":
if index is None:
raise enoent(virtual)
result = await index.get(idx_key)
if result.entry is None:
await _populate_via_parent(accessor, idx_key, prefix, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return FileStat(
name=result.entry.vfs_name,
type=FileType.JSON,
modified=result.entry.remote_time or None,
extra={"user_id": result.entry.id},
)
if len(parts) == 4 and parts[0] == "teams" and parts[2] == "issues":
if index is None:
raise enoent(virtual)
result = await index.get(idx_key)
if result.entry is None:
await _populate_via_parent(accessor, idx_key, prefix, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return FileStat(
name=result.entry.vfs_name,
type=FileType.DIRECTORY,
modified=result.entry.remote_time or None,
extra={"issue_id": result.entry.id},
)
if len(parts) == 5 and parts[0] == "teams" and parts[2] == "issues":
if parts[4] == "issue.json":
issue_key = "/" + "/".join(parts[:4])
if index is not None:
result = await index.get(issue_key)
issue_id = result.entry.id if result.entry else None
else:
issue_id = None
return FileStat(
name="issue.json",
type=FileType.JSON,
extra={"issue_id": issue_id},
)
if parts[4] == "comments.jsonl":
issue_key = "/" + "/".join(parts[:4])
if index is not None:
result = await index.get(issue_key)
issue_id = result.entry.id if result.entry else None
else:
issue_id = None
return FileStat(
name="comments.jsonl",
type=FileType.TEXT,
extra={"issue_id": issue_id},
)
if len(parts) == 4 and parts[0] == "teams" and parts[2] in {
"projects", "cycles"
}:
if index is None:
raise enoent(virtual)
result = await index.get(idx_key)
if result.entry is None:
await _populate_via_parent(accessor, idx_key, prefix, index)
result = await index.get(idx_key)
if result.entry is None:
raise enoent(virtual)
return FileStat(
name=result.entry.vfs_name,
type=FileType.JSON,
modified=result.entry.remote_time or None,
extra={
"project_id" if parts[2] == "projects" else "cycle_id":
result.entry.id
},
)
raise enoent(virtual)