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
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:
@@ -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__ = []
|
||||
@@ -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", [])
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user