UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

160 lines • 7.01 kB
// ========= 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 { FileChangeKind } from "../../types.js"; import { eventAt, field, textField } from "../../events/payload.js"; import { getIssue, listTeams } from "./client.js"; import { issueDirname, teamDirname } from "./pathing.js"; // The issue fields a project file lists, so a change to one makes the team's // projects stale. const PROJECT_ROW_FIELDS = new Set([ 'title', 'stateId', 'projectId', 'archivedAt', 'teamId', ]); const ISSUE_FILES = ['issue.json', 'comments.jsonl']; /** * Map one Linear webhook delivery onto mount paths. * * The consumer owns the receiver and passes the delivery's `type` (the * `Linear-Event` header) with the JSON body. Nothing here runs a loop. `Issue` * and `Comment` map; every other entity maps to nothing. * * An issue is the directory `teams/<team>/issues/<KEY-N>__<id>` with * `issue.json` and `comments.jsonl`, both named as `readdir` names them. An * issue body carries its team, so its path needs no call; a comment names only * its issue, which is read once through `issue(id)` and remembered. A team the * mount's `teamIds` leaves out maps to nothing. * * A new issue creates both files and a removed one deletes its directory. An * archived issue leaves the listing as a removed one does, and one restored * from the archive comes back as new. An issue moved to another team changes * its key too, so its old place is gone: the old team's `issues` is UNKNOWN. A * project file lists its team's issues with their keys, titles and states, so * an issue that is or was in a project and is created, removed, archived, * moved or changes one of those is UNKNOWN on its team's `projects`. A comment * created, edited or removed updates its issue's `comments.jsonl`. * * Mirrors Python `LinearEventHook` (`core/linear/events.py`). */ export class LinearEventHook { accessor; teams = new Map(); issues = new Map(); constructor(accessor) { this.accessor = accessor; } async teamDir(teamId, team) { if (teamId === null) return null; const wanted = this.accessor.teamIds; if (wanted !== null && wanted.length > 0 && !wanted.includes(teamId)) return null; if (textField(team, 'key') !== null) { this.teams.set(teamId, { ...team }); } if (!this.teams.has(teamId)) { for (const listed of await listTeams(this.accessor.transport)) { this.teams.set(String(listed.id), listed); } } const known = this.teams.get(teamId); return known !== undefined ? `teams/${teamDirname(known)}` : null; } async issueDir(issue) { const issueId = textField(issue, 'id'); const team = field(issue, 'team'); const teamId = textField(issue, 'teamId') ?? textField(team, 'id'); const teamDir = await this.teamDir(teamId, team); if (issueId === null) return null; if (teamDir === null || teamId === null) { this.issues.delete(issueId); return null; } let key = textField(issue, 'identifier'); const number = field(issue, 'number'); const prefix = this.teams.get(teamId)?.key; if (key === null && typeof prefix === 'string' && typeof number === 'number' && Number.isInteger(number)) { key = `${prefix}-${String(number)}`; } const place = `${teamDir}/issues/` + issueDirname({ id: issueId, identifier: key }); this.issues.set(issueId, place); return place; } async issue(root, action, payload) { const data = field(payload, 'data'); const before = field(payload, 'updatedFrom'); const changed = new Set(before !== null && typeof before === 'object' && !Array.isArray(before) ? Object.keys(before) : []); const inProject = textField(data, 'projectId') !== null || changed.has('projectId'); const listed = inProject && (action !== 'update' || [...changed].some((k) => PROJECT_ROW_FIELDS.has(k))); const events = []; const teams = []; const oldTeam = textField(before, 'teamId'); const oldDir = await this.teamDir(oldTeam, null); if (oldDir !== null) { teams.push(oldDir); events.push(eventAt(root, `${oldDir}/issues`, FileChangeKind.UNKNOWN)); } const place = await this.issueDir(data); if (place !== null) { teams.push(place.slice(0, place.lastIndexOf('/issues/'))); const archived = field(data, 'archivedAt') !== null; if (action === 'remove' || (archived && changed.has('archivedAt'))) { events.push(eventAt(root, place, FileChangeKind.DELETE)); } else if (action === 'create' || oldTeam !== null || field(before, 'archivedAt') !== null) { for (const name of ISSUE_FILES) { events.push(eventAt(root, `${place}/${name}`, FileChangeKind.CREATE)); } } else if (!archived) { events.push(eventAt(root, `${place}/issue.json`, FileChangeKind.UPDATE)); } } if (listed) { for (const team of teams) events.push(eventAt(root, `${team}/projects`, FileChangeKind.UNKNOWN)); } return events; } async comment(root, payload) { const data = field(payload, 'data'); const issueId = textField(data, 'issueId') ?? textField(field(data, 'issue'), 'id'); if (issueId === null) return []; let place = this.issues.get(issueId) ?? null; if (place === null) { const issue = await getIssue(this.accessor.transport, issueId); place = await this.issueDir(issue); } if (place === null) return []; return [eventAt(root, `${place}/comments.jsonl`, FileChangeKind.UPDATE)]; } async toEvents(root, eventType, payload) { if (eventType === 'Issue') return this.issue(root, textField(payload, 'action'), payload); if (eventType === 'Comment') return this.comment(root, payload); return []; } } //# sourceMappingURL=events.js.map