causly-server 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +120 -0
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +377 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Linear tools over the GraphQL API. No new dependencies; an API key is all that is needed.
|
|
2
|
+
//
|
|
3
|
+
// linear_get_viewer check the key, show who it belongs to
|
|
4
|
+
// linear_list_teams teams with their workflow states and labels (names the other tools accept)
|
|
5
|
+
// linear_list_issues find issues by team, assignee, state, label, priority, text
|
|
6
|
+
// linear_get_issue one issue in full, with recent comments
|
|
7
|
+
// linear_create_issue create an issue
|
|
8
|
+
// linear_update_issue change state, assignee, priority, labels, title, description, due date
|
|
9
|
+
// linear_add_comment comment on an issue
|
|
10
|
+
//
|
|
11
|
+
// Credentials: LINEAR_API_KEY (Linear > Settings > Account > Security & access > Personal API keys).
|
|
12
|
+
// There is deliberately no delete tool: close an issue by moving it to a Canceled state.
|
|
13
|
+
import { UNTRUSTED_NOTICE } from "./webOps.js";
|
|
14
|
+
|
|
15
|
+
const API = "https://api.linear.app/graphql";
|
|
16
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const IDENT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
19
|
+
const STATE_TYPES = ["triage", "backlog", "unstarted", "started", "completed", "canceled"];
|
|
20
|
+
const PRIORITIES = { none: 0, urgent: 1, high: 2, medium: 3, low: 4 };
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------- client
|
|
23
|
+
|
|
24
|
+
function client(deps = {}) {
|
|
25
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
26
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
27
|
+
if (!apiKey) throw new Error("Linear is not set up. Missing: LINEAR_API_KEY (create a personal API key in Linear settings and add it to .env).");
|
|
28
|
+
const scrub = (text) => String(text).split(apiKey).join("[REDACTED]");
|
|
29
|
+
return async function gql(query, variables = {}) {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetchFn(API, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "content-type": "application/json", authorization: apiKey.startsWith("lin_oauth_") ? `Bearer ${apiKey}` : apiKey },
|
|
35
|
+
body: JSON.stringify({ query, variables }),
|
|
36
|
+
signal: AbortSignal.timeout(20_000),
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`Could not reach Linear: ${scrub(err.message)}`);
|
|
40
|
+
}
|
|
41
|
+
const data = await res.json().catch(() => ({}));
|
|
42
|
+
const err = data.errors?.[0];
|
|
43
|
+
if (res.status === 401 || res.status === 403 || err?.extensions?.type === "authentication error") {
|
|
44
|
+
throw new Error("Linear rejected the API key. Check LINEAR_API_KEY.");
|
|
45
|
+
}
|
|
46
|
+
if (res.status === 429 || err?.extensions?.code === "RATELIMITED") throw new Error("Linear rate limit hit. Wait a minute and try again.");
|
|
47
|
+
if (!res.ok || err) {
|
|
48
|
+
const msg = err?.extensions?.userPresentableMessage || err?.message || `HTTP ${res.status}`;
|
|
49
|
+
throw new Error(`Linear error: ${scrub(msg)}`);
|
|
50
|
+
}
|
|
51
|
+
return data.data;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------- shaping
|
|
56
|
+
|
|
57
|
+
const ISSUE_FIELDS = `id identifier title priority priorityLabel dueDate estimate createdAt updatedAt url
|
|
58
|
+
state { name type } assignee { name email } labels { nodes { name } } team { key name } project { name }`;
|
|
59
|
+
|
|
60
|
+
function simplify(i, { description = false } = {}) {
|
|
61
|
+
return {
|
|
62
|
+
identifier: i.identifier,
|
|
63
|
+
title: i.title,
|
|
64
|
+
state: i.state?.name,
|
|
65
|
+
state_type: i.state?.type,
|
|
66
|
+
priority: i.priorityLabel,
|
|
67
|
+
assignee: i.assignee ? i.assignee.name : null,
|
|
68
|
+
...(i.assignee?.email ? { assignee_email: i.assignee.email } : {}),
|
|
69
|
+
labels: (i.labels?.nodes || []).map((l) => l.name),
|
|
70
|
+
team: i.team?.key,
|
|
71
|
+
...(i.project ? { project: i.project.name } : {}),
|
|
72
|
+
...(i.dueDate ? { due_date: i.dueDate } : {}),
|
|
73
|
+
...(i.estimate != null ? { estimate: i.estimate } : {}),
|
|
74
|
+
...(description && i.description ? { description: String(i.description).slice(0, 4000) } : {}),
|
|
75
|
+
created: i.createdAt,
|
|
76
|
+
updated: i.updatedAt,
|
|
77
|
+
url: i.url,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parsePriority(v) {
|
|
82
|
+
if (v === undefined || v === null || v === "") return undefined;
|
|
83
|
+
const key = String(v).trim().toLowerCase();
|
|
84
|
+
if (key in PRIORITIES) return PRIORITIES[key];
|
|
85
|
+
const n = Number(key);
|
|
86
|
+
if (Number.isInteger(n) && n >= 0 && n <= 4) return n;
|
|
87
|
+
throw new Error("priority must be urgent, high, medium, low or none (or 0-4, where 1 is urgent).");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkDueDate(v) {
|
|
91
|
+
if (v === undefined || v === null || v === "") return undefined;
|
|
92
|
+
const s = String(v).trim();
|
|
93
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(s))) throw new Error("due_date must look like 2026-10-31.");
|
|
94
|
+
return s;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkIssueRef(ref) {
|
|
98
|
+
const s = String(ref ?? "").trim();
|
|
99
|
+
if (!IDENT.test(s) && !UUID.test(s)) throw new Error("issue must be an identifier like ENG-123 (or an issue id).");
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const teamFilter = (team) => (UUID.test(team) ? { id: { eq: team } } : { key: { eqIgnoreCase: String(team).trim() } });
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------- resolvers (names -> ids)
|
|
106
|
+
|
|
107
|
+
async function resolveUser(gql, who) {
|
|
108
|
+
const s = String(who).trim();
|
|
109
|
+
if (s.toLowerCase() === "me") return (await gql("query { viewer { id } }")).viewer.id;
|
|
110
|
+
const data = await gql(
|
|
111
|
+
`query($f: UserFilter) { users(first: 10, filter: $f) { nodes { id name email active } } }`,
|
|
112
|
+
{ f: { or: [{ email: { eqIgnoreCase: s } }, { name: { containsIgnoreCase: s } }, { displayName: { containsIgnoreCase: s } }] } }
|
|
113
|
+
);
|
|
114
|
+
const found = (data.users.nodes || []).filter((u) => u.active !== false);
|
|
115
|
+
const exact = found.filter((u) => u.email?.toLowerCase() === s.toLowerCase());
|
|
116
|
+
const pick = exact.length === 1 ? exact : found;
|
|
117
|
+
if (pick.length === 1) return pick[0].id;
|
|
118
|
+
if (!pick.length) throw new Error(`No Linear user matches "${s}". Use their email address or "me".`);
|
|
119
|
+
throw new Error(`"${s}" matches several people (${pick.map((u) => `${u.name} <${u.email}>`).join(", ")}). Use an email address.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadTeams(gql) {
|
|
123
|
+
const data = await gql(`query { teams(first: 50) { nodes { id key name states(first: 30) { nodes { id name type } } } } }`);
|
|
124
|
+
return data.teams.nodes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findTeam(teams, ref) {
|
|
128
|
+
const s = String(ref ?? "").trim();
|
|
129
|
+
if (!s) throw new Error("team is required (the team key, like ENG).");
|
|
130
|
+
const hit = teams.find((t) => t.key.toLowerCase() === s.toLowerCase() || t.name.toLowerCase() === s.toLowerCase() || t.id === s);
|
|
131
|
+
if (!hit) throw new Error(`No team "${s}". Teams: ${teams.map((t) => t.key).join(", ")}.`);
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findState(team, name) {
|
|
136
|
+
const s = String(name).trim().toLowerCase();
|
|
137
|
+
const states = team.states.nodes;
|
|
138
|
+
const hit = states.find((x) => x.name.toLowerCase() === s);
|
|
139
|
+
if (!hit) throw new Error(`Team ${team.key} has no state "${name}". States: ${states.map((x) => x.name).join(", ")}.`);
|
|
140
|
+
return hit.id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Label names -> ids. Workspace labels and the team's own labels are both allowed. */
|
|
144
|
+
async function resolveLabels(gql, teamId, names) {
|
|
145
|
+
const data = await gql(`query { issueLabels(first: 250) { nodes { id name team { id } } } }`);
|
|
146
|
+
const usable = data.issueLabels.nodes.filter((l) => !l.team || l.team.id === teamId);
|
|
147
|
+
return names.map((n) => {
|
|
148
|
+
const hit = usable.find((l) => l.name.toLowerCase() === String(n).trim().toLowerCase());
|
|
149
|
+
if (!hit) throw new Error(`No label "${n}" for this team. Labels: ${usable.map((l) => l.name).join(", ") || "(none)"}.`);
|
|
150
|
+
return hit.id;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const stringList = (v, label) => {
|
|
155
|
+
if (v === undefined || v === null) return [];
|
|
156
|
+
if (!Array.isArray(v)) throw new Error(`${label} must be a list of names.`);
|
|
157
|
+
return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- tools
|
|
161
|
+
|
|
162
|
+
export async function getViewer(_input = {}, deps = {}) {
|
|
163
|
+
const gql = client(deps);
|
|
164
|
+
const { viewer, organization } = await gql(`query { viewer { id name email } organization { name urlKey } }`);
|
|
165
|
+
return { id: viewer.id, name: viewer.name, email: viewer.email, workspace: organization?.name, workspace_key: organization?.urlKey };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function listTeams(_input = {}, deps = {}) {
|
|
169
|
+
const gql = client(deps);
|
|
170
|
+
const [teams, labels] = await Promise.all([
|
|
171
|
+
loadTeams(gql),
|
|
172
|
+
gql(`query { issueLabels(first: 250) { nodes { name team { key } } } }`),
|
|
173
|
+
]);
|
|
174
|
+
return {
|
|
175
|
+
count: teams.length,
|
|
176
|
+
teams: teams.map((t) => ({
|
|
177
|
+
key: t.key,
|
|
178
|
+
name: t.name,
|
|
179
|
+
states: t.states.nodes.map((s) => ({ name: s.name, type: s.type })),
|
|
180
|
+
labels: labels.issueLabels.nodes.filter((l) => !l.team || l.team.key === t.key).map((l) => l.name),
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function listIssues({ team, assignee, state, label, priority, query, project, include_closed = false, limit = 25 } = {}, deps = {}) {
|
|
186
|
+
const gql = client(deps);
|
|
187
|
+
const filter = {};
|
|
188
|
+
if (team) filter.team = teamFilter(String(team));
|
|
189
|
+
if (assignee) {
|
|
190
|
+
const a = String(assignee).trim();
|
|
191
|
+
filter.assignee = a.toLowerCase() === "me" ? { isMe: { eq: true } } : a.toLowerCase() === "unassigned" ? { null: true } : { id: { eq: await resolveUser(gql, a) } };
|
|
192
|
+
}
|
|
193
|
+
if (state) {
|
|
194
|
+
const s = String(state).trim().toLowerCase();
|
|
195
|
+
filter.state = STATE_TYPES.includes(s) ? { type: { eq: s } } : { name: { eqIgnoreCase: String(state).trim() } };
|
|
196
|
+
} else if (!include_closed) {
|
|
197
|
+
filter.state = { type: { nin: ["completed", "canceled"] } };
|
|
198
|
+
}
|
|
199
|
+
if (label) filter.labels = { name: { eqIgnoreCase: String(label).trim() } };
|
|
200
|
+
const p = parsePriority(priority);
|
|
201
|
+
if (p !== undefined) filter.priority = { eq: p };
|
|
202
|
+
if (project) filter.project = { name: { containsIgnoreCase: String(project).trim() } };
|
|
203
|
+
if (query && String(query).trim()) {
|
|
204
|
+
const q = String(query).trim();
|
|
205
|
+
filter.or = [{ title: { containsIgnoreCase: q } }, { description: { containsIgnoreCase: q } }];
|
|
206
|
+
}
|
|
207
|
+
const max = clamp(Math.trunc(Number(limit)) || 25, 1, 100);
|
|
208
|
+
const data = await gql(
|
|
209
|
+
`query($filter: IssueFilter, $first: Int) { issues(first: $first, filter: $filter, orderBy: updatedAt) { nodes { ${ISSUE_FIELDS} } } }`,
|
|
210
|
+
{ filter, first: max }
|
|
211
|
+
);
|
|
212
|
+
const issues = data.issues.nodes.map((i) => simplify(i));
|
|
213
|
+
return {
|
|
214
|
+
count: issues.length,
|
|
215
|
+
...(include_closed || state ? {} : { note: "Completed and canceled issues are hidden. Pass include_closed: true or a state to see them." }),
|
|
216
|
+
issues,
|
|
217
|
+
notice: UNTRUSTED_NOTICE,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function getIssue({ issue } = {}, deps = {}) {
|
|
222
|
+
const ref = checkIssueRef(issue);
|
|
223
|
+
const gql = client(deps);
|
|
224
|
+
const data = await gql(
|
|
225
|
+
`query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} description comments(first: 20) { nodes { body createdAt user { name } } } } }`,
|
|
226
|
+
{ id: ref }
|
|
227
|
+
);
|
|
228
|
+
if (!data.issue) throw new Error(`No issue ${ref}.`);
|
|
229
|
+
return {
|
|
230
|
+
issue: simplify(data.issue, { description: true }),
|
|
231
|
+
comments: (data.issue.comments?.nodes || []).map((c) => ({ by: c.user?.name ?? "unknown", at: c.createdAt, text: String(c.body).slice(0, 1500) })),
|
|
232
|
+
notice: UNTRUSTED_NOTICE,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function createIssue({ team, title, description, priority, assignee, state, labels, due_date } = {}, deps = {}) {
|
|
237
|
+
if (!title || !String(title).trim()) throw new Error("title is required.");
|
|
238
|
+
const prio = parsePriority(priority);
|
|
239
|
+
const due = checkDueDate(due_date);
|
|
240
|
+
const labelNames = stringList(labels, "labels");
|
|
241
|
+
const gql = client(deps);
|
|
242
|
+
|
|
243
|
+
const t = findTeam(await loadTeams(gql), team);
|
|
244
|
+
const input = { teamId: t.id, title: String(title).trim() };
|
|
245
|
+
if (description) input.description = String(description);
|
|
246
|
+
if (prio !== undefined) input.priority = prio;
|
|
247
|
+
if (due) input.dueDate = due;
|
|
248
|
+
if (state) input.stateId = findState(t, state);
|
|
249
|
+
if (assignee) input.assigneeId = await resolveUser(gql, assignee);
|
|
250
|
+
if (labelNames.length) input.labelIds = await resolveLabels(gql, t.id, labelNames);
|
|
251
|
+
|
|
252
|
+
const data = await gql(`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input });
|
|
253
|
+
if (!data.issueCreate?.success) throw new Error("Linear did not create the issue.");
|
|
254
|
+
return { created: true, issue: simplify(data.issueCreate.issue) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function updateIssue({ issue, title, description, priority, assignee, state, add_labels, remove_labels, due_date } = {}, deps = {}) {
|
|
258
|
+
const ref = checkIssueRef(issue);
|
|
259
|
+
const prio = parsePriority(priority);
|
|
260
|
+
const due = due_date === null || due_date === "" ? null : checkDueDate(due_date);
|
|
261
|
+
const add = stringList(add_labels, "add_labels");
|
|
262
|
+
const remove = stringList(remove_labels, "remove_labels");
|
|
263
|
+
const gql = client(deps);
|
|
264
|
+
|
|
265
|
+
const current = await gql(
|
|
266
|
+
`query($id: String!) { issue(id: $id) { id identifier team { id key states(first: 100) { nodes { id name type } } } labels { nodes { id name } } } }`,
|
|
267
|
+
{ id: ref }
|
|
268
|
+
);
|
|
269
|
+
if (!current.issue) throw new Error(`No issue ${ref}.`);
|
|
270
|
+
const cur = current.issue;
|
|
271
|
+
|
|
272
|
+
const input = {};
|
|
273
|
+
if (title !== undefined) input.title = String(title);
|
|
274
|
+
if (description !== undefined) input.description = String(description);
|
|
275
|
+
if (prio !== undefined) input.priority = prio;
|
|
276
|
+
if (due !== undefined) input.dueDate = due;
|
|
277
|
+
if (state) input.stateId = findState(cur.team, state);
|
|
278
|
+
if (assignee !== undefined) {
|
|
279
|
+
input.assigneeId = String(assignee).trim().toLowerCase() === "unassigned" ? null : await resolveUser(gql, assignee);
|
|
280
|
+
}
|
|
281
|
+
if (add.length || remove.length) {
|
|
282
|
+
const removeIds = new Set(cur.labels.nodes.filter((l) => remove.some((n) => n.toLowerCase() === l.name.toLowerCase())).map((l) => l.id));
|
|
283
|
+
const addIds = add.length ? await resolveLabels(gql, cur.team.id, add) : [];
|
|
284
|
+
input.labelIds = [...new Set([...cur.labels.nodes.map((l) => l.id).filter((id) => !removeIds.has(id)), ...addIds])];
|
|
285
|
+
}
|
|
286
|
+
if (!Object.keys(input).length) throw new Error("Nothing to change. Pass title, description, priority, assignee, state, add_labels, remove_labels or due_date.");
|
|
287
|
+
|
|
288
|
+
const data = await gql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: cur.id, input });
|
|
289
|
+
if (!data.issueUpdate?.success) throw new Error("Linear did not update the issue.");
|
|
290
|
+
return { updated: true, issue: simplify(data.issueUpdate.issue) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function addComment({ issue, body } = {}, deps = {}) {
|
|
294
|
+
const ref = checkIssueRef(issue);
|
|
295
|
+
if (!body || !String(body).trim()) throw new Error("body is required.");
|
|
296
|
+
const gql = client(deps);
|
|
297
|
+
const found = await gql(`query($id: String!) { issue(id: $id) { id identifier } }`, { id: ref });
|
|
298
|
+
if (!found.issue) throw new Error(`No issue ${ref}.`);
|
|
299
|
+
const data = await gql(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id url } } }`, {
|
|
300
|
+
input: { issueId: found.issue.id, body: String(body) },
|
|
301
|
+
});
|
|
302
|
+
if (!data.commentCreate?.success) throw new Error("Linear did not add the comment.");
|
|
303
|
+
return { commented: true, issue: found.issue.identifier, url: data.commentCreate.comment?.url };
|
|
304
|
+
}
|
package/tools/logger.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { redactSecrets, redactSecretsInString, PERMISSION_LEVELS } from "./security.js";
|
|
5
|
+
import { dataDir } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
const LOG_DIR = path.join(dataDir(), "logs");
|
|
8
|
+
const LOG_FILE = path.join(LOG_DIR, "activity.log");
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
11
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Appends a structured JSON log line for every tool invocation — one
|
|
16
|
+
* self-contained JSON object per line (JSONL), so it can be parsed/
|
|
17
|
+
* searched/aggregated later without regex-scraping free text.
|
|
18
|
+
*
|
|
19
|
+
* Never throws — logging failures must not break tool execution.
|
|
20
|
+
* Secret-looking fields (tokens, passwords, keys, etc.) are always
|
|
21
|
+
* redacted before anything touches disk — this applies to the input
|
|
22
|
+
* object AND the free-text details/error string.
|
|
23
|
+
*
|
|
24
|
+
* Fields: timestamp, operation_id (correlates a call across retries),
|
|
25
|
+
* tool, risk_level, status, input (redacted), details (redacted),
|
|
26
|
+
* duration_ms.
|
|
27
|
+
*/
|
|
28
|
+
export function logActivity(toolName, input, status, details = "", durationMs = null, operationId = crypto.randomUUID()) {
|
|
29
|
+
try {
|
|
30
|
+
const entry = {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
operation_id: operationId,
|
|
33
|
+
tool: toolName,
|
|
34
|
+
risk_level: PERMISSION_LEVELS[toolName] || "MEDIUM",
|
|
35
|
+
status,
|
|
36
|
+
input: redactSecrets(input),
|
|
37
|
+
details: redactSecretsInString(details),
|
|
38
|
+
duration_ms: durationMs,
|
|
39
|
+
};
|
|
40
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n", "utf8");
|
|
41
|
+
return entry;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Swallow logging errors — never let logging break the actual tool call.
|
|
44
|
+
console.error("Logger error:", err.message);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|