@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
160 lines • 7.01 kB
JavaScript
// ========= 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