@phnx-labs/agents-cli 1.20.92 → 1.21.0
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/CHANGELOG.md +385 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/events.js +91 -1
- package/dist/commands/exec.js +13 -0
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.js +355 -48
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +16 -8
- package/dist/commands/setup-browser.js +5 -1
- package/dist/commands/setup-fleet.js +5 -0
- package/dist/commands/setup-preferences.d.ts +53 -0
- package/dist/commands/setup-preferences.js +142 -0
- package/dist/commands/setup.js +8 -9
- package/dist/commands/ssh.js +220 -10
- package/dist/commands/sync.d.ts +2 -2
- package/dist/commands/sync.js +13 -12
- package/dist/commands/view.js +4 -0
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +38 -6
- package/dist/lib/activity.d.ts +15 -0
- package/dist/lib/activity.js +26 -2
- package/dist/lib/auto-dispatch.d.ts +6 -1
- package/dist/lib/auto-dispatch.js +7 -2
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/daemon.d.ts +13 -0
- package/dist/lib/daemon.js +80 -60
- package/dist/lib/device-config.d.ts +82 -0
- package/dist/lib/device-config.js +296 -0
- package/dist/lib/event-provenance.d.ts +19 -0
- package/dist/lib/event-provenance.js +48 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +17 -5
- package/dist/lib/events.js +59 -56
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +2 -0
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/linear-project-counts.d.ts +129 -0
- package/dist/lib/linear-project-counts.js +206 -0
- package/dist/lib/linear-projects.d.ts +62 -0
- package/dist/lib/linear-projects.js +144 -0
- package/dist/lib/mailbox-gc.js +30 -7
- package/dist/lib/mailbox.d.ts +14 -1
- package/dist/lib/mailbox.js +35 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +5 -21
- package/dist/lib/menubar/install-menubar.js +35 -44
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/overdue.d.ts +1 -1
- package/dist/lib/overdue.js +54 -19
- package/dist/lib/project-import.d.ts +96 -0
- package/dist/lib/project-import.js +171 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/routines.d.ts +53 -0
- package/dist/lib/routines.js +105 -4
- package/dist/lib/rules/run-sync.d.ts +18 -0
- package/dist/lib/rules/run-sync.js +92 -0
- package/dist/lib/run-notify.js +3 -0
- package/dist/lib/scheduler.js +12 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/active.d.ts +16 -2
- package/dist/lib/session/active.js +8 -4
- package/dist/lib/session/bundle.js +5 -1
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.d.ts +1 -15
- package/dist/lib/session/remote-list.js +22 -111
- package/dist/lib/session/sync/config.d.ts +13 -5
- package/dist/lib/session/sync/config.js +21 -13
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +19 -2
- package/dist/lib/state.js +99 -10
- package/dist/lib/sync-umbrella.d.ts +11 -23
- package/dist/lib/sync-umbrella.js +14 -45
- package/dist/lib/teams/agents.d.ts +35 -3
- package/dist/lib/teams/agents.js +52 -8
- package/dist/lib/teams/scheduler.d.ts +29 -2
- package/dist/lib/teams/scheduler.js +60 -16
- package/dist/lib/types.d.ts +22 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -2
- package/dist/commands/drive.d.ts +0 -10
- package/dist/commands/drive.js +0 -183
- package/dist/commands/hq.d.ts +0 -2
- package/dist/commands/hq.js +0 -58
- package/dist/commands/sessions-sync.d.ts +0 -17
- package/dist/commands/sessions-sync.js +0 -135
- package/dist/commands/sync-provision.d.ts +0 -23
- package/dist/commands/sync-provision.js +0 -107
- package/dist/lib/drive-sync.d.ts +0 -45
- package/dist/lib/drive-sync.js +0 -238
- package/dist/lib/hq/floor.d.ts +0 -87
- package/dist/lib/hq/floor.js +0 -243
- package/dist/lib/session/sync/crdt.d.ts +0 -44
- package/dist/lib/session/sync/crdt.js +0 -119
- package/dist/lib/session/sync/manifest.d.ts +0 -62
- package/dist/lib/session/sync/manifest.js +0 -100
- package/dist/lib/session/sync/provision.d.ts +0 -49
- package/dist/lib/session/sync/provision.js +0 -91
- package/dist/lib/session/sync/r2.d.ts +0 -32
- package/dist/lib/session/sync/r2.js +0 -121
- package/dist/lib/session/sync/sync.d.ts +0 -106
- package/dist/lib/session/sync/sync.js +0 -374
package/dist/lib/events.js
CHANGED
|
@@ -16,11 +16,9 @@ import * as path from 'path';
|
|
|
16
16
|
import * as os from 'os';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
19
|
-
import { parseSshConnection } from './session/provenance.js';
|
|
20
19
|
import { ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
21
20
|
import { getUserAgentsDir } from './state.js';
|
|
22
|
-
import {
|
|
23
|
-
import { machineId } from './machine-id.js';
|
|
21
|
+
import { stampProvenance, resetEventProvenanceForTest } from './event-provenance.js';
|
|
24
22
|
/** Lazy perf warehouse write — avoids a hard cycle at module load. */
|
|
25
23
|
function recordPerfTiming(payload) {
|
|
26
24
|
try {
|
|
@@ -72,9 +70,50 @@ function isDisabled() {
|
|
|
72
70
|
const DIR_MODE = 0o700;
|
|
73
71
|
/** File permissions (owner read/write only). */
|
|
74
72
|
const FILE_MODE = 0o600;
|
|
73
|
+
/**
|
|
74
|
+
* Every {@link EventType}, as a runtime-checkable table.
|
|
75
|
+
*
|
|
76
|
+
* Typed `Record<EventType, true>` on purpose: the object literal is
|
|
77
|
+
* exhaustiveness-checked at COMPILE time, so adding a member to the union
|
|
78
|
+
* without adding it here fails `tsc`. That is what keeps the runtime validator
|
|
79
|
+
* (`isEventType`, used by `agents events emit` to reject an unknown kind from an
|
|
80
|
+
* out-of-process producer) from silently drifting behind the union.
|
|
81
|
+
*/
|
|
82
|
+
const EVENT_TYPE_TABLE = {
|
|
83
|
+
'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
|
|
84
|
+
'version.install': true, 'version.switch': true, 'version.remove': true,
|
|
85
|
+
'skill.install': true, 'skill.remove': true,
|
|
86
|
+
'browser.launch': true, 'browser.close': true, 'browser.navigate': true, 'browser.screenshot': true,
|
|
87
|
+
'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
|
|
88
|
+
'cloud.dispatch': true, 'cloud.complete': true, 'cloud.cancel': true, 'cloud.message': true,
|
|
89
|
+
'teams.create': true, 'teams.add': true, 'teams.start': true, 'teams.complete': true, 'teams.disband': true,
|
|
90
|
+
'hook.fire': true, 'hook.complete': true, 'hook.error': true,
|
|
91
|
+
'mcp.add': true, 'mcp.remove': true, 'mcp.register': true,
|
|
92
|
+
'resource.sync': true,
|
|
93
|
+
'rotation.resolved': true,
|
|
94
|
+
'command.start': true, 'command.end': true,
|
|
95
|
+
'perf.timing': true,
|
|
96
|
+
'session.start': true, 'session.end': true,
|
|
97
|
+
'webhook.received': true, 'webhook.authorized': true, 'webhook.rejected': true, 'webhook.matched': true,
|
|
98
|
+
'webhook.fired': true, 'webhook.handler.start': true, 'webhook.handler.end': true,
|
|
99
|
+
'plan.created': true, 'pr.opened': true, 'pr.merged': true, 'worktree.created': true,
|
|
100
|
+
'worktree.removed': true, 'commit.created': true, 'pushed': true, 'subagent.spawned': true,
|
|
101
|
+
'artifact.created': true, 'task.completed': true, 'checklist.created': true, 'status.posted': true,
|
|
102
|
+
'file.edited': true,
|
|
103
|
+
'factory.command': true, 'factory.action': true, 'factory.uri': true, 'factory.launch': true,
|
|
104
|
+
'friction': true, 'error': true, 'warn': true, 'info': true, 'debug': true,
|
|
105
|
+
};
|
|
106
|
+
/** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
|
|
107
|
+
export const EVENT_TYPES = Object.keys(EVENT_TYPE_TABLE);
|
|
108
|
+
const EVENT_TYPE_SET = new Set(EVENT_TYPES);
|
|
109
|
+
/** Runtime guard for an event kind arriving from outside this process. */
|
|
110
|
+
export function isEventType(value) {
|
|
111
|
+
return EVENT_TYPE_SET.has(value);
|
|
112
|
+
}
|
|
75
113
|
const AUDIT_EVENTS = new Set([
|
|
76
114
|
'command.start', 'command.end',
|
|
77
|
-
'secrets.get', 'secrets.unlocked', 'secrets.
|
|
115
|
+
'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view',
|
|
116
|
+
'secrets.set', 'secrets.delete', 'secrets.rename',
|
|
78
117
|
'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
|
|
79
118
|
'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
|
|
80
119
|
'version.install', 'version.switch', 'version.remove',
|
|
@@ -82,6 +121,11 @@ const AUDIT_EVENTS = new Set([
|
|
|
82
121
|
'mcp.add', 'mcp.remove', 'mcp.register',
|
|
83
122
|
'rotation.resolved',
|
|
84
123
|
'session.start', 'session.end',
|
|
124
|
+
// An external process reaching into the user's editor (the CLI's
|
|
125
|
+
// vscodium-agent backend driving `/spawn` / `/inject` / `/focus`) is a
|
|
126
|
+
// "who reached in from outside" fact, which is what the audit lane answers.
|
|
127
|
+
// The other factory.* kinds are ordinary info — a palette press is not audit.
|
|
128
|
+
'factory.uri',
|
|
85
129
|
]);
|
|
86
130
|
export function levelFor(event) {
|
|
87
131
|
if (event === 'warn')
|
|
@@ -283,59 +327,21 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
|
|
|
283
327
|
}
|
|
284
328
|
return { kind: stdoutIsTTY ? 'terminal' : 'script' };
|
|
285
329
|
}
|
|
286
|
-
|
|
287
|
-
* Who is running this process and from where. Derived once per process from the
|
|
288
|
-
* OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
|
|
289
|
-
* cached — provenance can't change mid-process, so every emit() pays for it once.
|
|
290
|
-
*/
|
|
291
|
-
let _origin;
|
|
292
|
-
function auditOrigin() {
|
|
293
|
-
if (_origin)
|
|
294
|
-
return _origin;
|
|
295
|
-
let osUser = 'unknown';
|
|
296
|
-
try {
|
|
297
|
-
osUser = os.userInfo().username;
|
|
298
|
-
}
|
|
299
|
-
catch {
|
|
300
|
-
// Container/edge cases where the uid has no passwd entry.
|
|
301
|
-
}
|
|
302
|
-
const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
|
|
303
|
-
const actor = resolveActor();
|
|
304
|
-
_origin = {
|
|
305
|
-
osUser,
|
|
306
|
-
transport: ssh ? 'ssh' : 'local',
|
|
307
|
-
...(ssh ? { sshClientIp: ssh.clientIp } : {}),
|
|
308
|
-
actor: actor.id,
|
|
309
|
-
kind: actor.kind,
|
|
310
|
-
};
|
|
311
|
-
return _origin;
|
|
312
|
-
}
|
|
313
|
-
/** This machine's normalized device id, resolved once — it can't change mid-process. */
|
|
314
|
-
let _machineId;
|
|
315
|
-
function cachedMachineId() {
|
|
316
|
-
return (_machineId ??= machineId());
|
|
317
|
-
}
|
|
318
|
-
function resolveProvenance(env = process.env) {
|
|
319
|
-
const p = {};
|
|
320
|
-
const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
|
|
321
|
-
if (sessionId)
|
|
322
|
-
p.sessionId = sessionId;
|
|
323
|
-
if (env.AGENTS_AGENT_NAME)
|
|
324
|
-
p.agent = env.AGENTS_AGENT_NAME;
|
|
325
|
-
if (env.AGENT_LAUNCH_ID)
|
|
326
|
-
p.launchId = env.AGENT_LAUNCH_ID;
|
|
327
|
-
if (env.AGENTS_PARENT_SESSION_ID)
|
|
328
|
-
p.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
|
|
329
|
-
return p;
|
|
330
|
-
}
|
|
330
|
+
// ─── Audit attribution ────────────────────────────────────────────────────────
|
|
331
331
|
// ─── Core API ─────────────────────────────────────────────────────────────────
|
|
332
332
|
/**
|
|
333
333
|
* Emit a structured event to the append-only audit log.
|
|
334
334
|
*
|
|
335
335
|
* @param event - The event type
|
|
336
336
|
* @param payload - Event-specific data (agent, version, cwd, etc.)
|
|
337
|
+
* @param overrides - Envelope fields the CALLER owns rather than the writer.
|
|
338
|
+
* Only `ts` today: a batched out-of-process producer (`agents events emit`)
|
|
339
|
+
* records when each event HAPPENED, but flushes them together later, so
|
|
340
|
+
* stamping write-time would collapse a whole batch onto the flush instant and
|
|
341
|
+
* corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
|
|
342
|
+
* *payload* still cannot inject it — this explicit channel is the only way in.
|
|
337
343
|
*/
|
|
338
|
-
export function emit(event, payload = {}) {
|
|
344
|
+
export function emit(event, payload = {}, overrides = {}) {
|
|
339
345
|
if (isDisabled())
|
|
340
346
|
return;
|
|
341
347
|
try {
|
|
@@ -344,13 +350,12 @@ export function emit(event, payload = {}) {
|
|
|
344
350
|
const safePayload = sanitizePayload(payload);
|
|
345
351
|
const record = {
|
|
346
352
|
// Provenance floor first: env-sourced defaults an explicit payload overrides.
|
|
347
|
-
...
|
|
353
|
+
...stampProvenance(),
|
|
348
354
|
...safePayload,
|
|
349
|
-
ts: new Date().toISOString(),
|
|
355
|
+
ts: overrides.ts ?? new Date().toISOString(),
|
|
350
356
|
tz: getTimezoneOffset(),
|
|
351
357
|
tzName: getTimezoneName(),
|
|
352
358
|
hostname: os.hostname(),
|
|
353
|
-
machineId: cachedMachineId(),
|
|
354
359
|
platform: os.platform(),
|
|
355
360
|
arch: os.arch(),
|
|
356
361
|
pid: process.pid,
|
|
@@ -359,7 +364,6 @@ export function emit(event, payload = {}) {
|
|
|
359
364
|
level: levelFor(event),
|
|
360
365
|
caller: caller.kind,
|
|
361
366
|
...(caller.session ? { session: caller.session } : {}),
|
|
362
|
-
...auditOrigin(),
|
|
363
367
|
};
|
|
364
368
|
const line = JSON.stringify(record) + '\n';
|
|
365
369
|
const logPath = eventsPath();
|
|
@@ -867,8 +871,7 @@ export function getLogsPath() {
|
|
|
867
871
|
}
|
|
868
872
|
export function _resetForTest(overrideEventsPath) {
|
|
869
873
|
_eventsPath = overrideEventsPath;
|
|
870
|
-
|
|
871
|
-
_machineId = undefined;
|
|
874
|
+
resetEventProvenanceForTest();
|
|
872
875
|
_chmoddedPath = undefined;
|
|
873
876
|
lastRotationCheck = 0;
|
|
874
877
|
}
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface MessageReceipt {
|
|
|
12
12
|
/** The message id this receipt describes. */
|
|
13
13
|
msgId: string;
|
|
14
14
|
/** Delivery lifecycle state. */
|
|
15
|
-
status: 'queued' | 'consumed' | 'continued';
|
|
15
|
+
status: 'queued' | 'consumed' | 'continued' | 'dropped' | 'expired';
|
|
16
16
|
/** ISO-8601 timestamp of the state transition. */
|
|
17
17
|
at: string;
|
|
18
18
|
/** Optional sender label for the message. */
|
package/dist/lib/feed.js
CHANGED
package/dist/lib/git.d.ts
CHANGED
|
@@ -41,8 +41,12 @@ export declare function assertValidBranchName(branch: string): void;
|
|
|
41
41
|
*
|
|
42
42
|
* Prefer this over `git.push(remote, branch)` whenever the branch comes from
|
|
43
43
|
* repo state rather than a hard-coded literal.
|
|
44
|
+
*
|
|
45
|
+
* Pass `targetBranch` to push the local `branch` to a differently-named remote
|
|
46
|
+
* branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
|
|
47
|
+
* working tree to a branch other than the checked-out one.
|
|
44
48
|
*/
|
|
45
|
-
export declare function pushOrigin(git: SimpleGit, branch: string): Promise<void>;
|
|
49
|
+
export declare function pushOrigin(git: SimpleGit, branch: string, targetBranch?: string): Promise<void>;
|
|
46
50
|
/** Parsed representation of a git source string (GitHub, generic URL, or local path). */
|
|
47
51
|
export interface GitSource {
|
|
48
52
|
type: 'github' | 'url' | 'local';
|
|
@@ -124,6 +128,8 @@ export declare function resolveGitHubUsername(): Promise<string | null>;
|
|
|
124
128
|
* Get the remote URL for origin in a git repo.
|
|
125
129
|
*/
|
|
126
130
|
export declare function getRemoteUrl(repoPath: string): Promise<string | null>;
|
|
131
|
+
/** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
|
|
132
|
+
export declare function getCurrentBranch(repoPath: string): Promise<string>;
|
|
127
133
|
/**
|
|
128
134
|
* Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
|
|
129
135
|
* same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
|
|
@@ -158,8 +164,13 @@ export type CommitAndPushResult = {
|
|
|
158
164
|
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
159
165
|
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
160
166
|
* there is nothing to commit.
|
|
167
|
+
*
|
|
168
|
+
* `targetBranch` pushes the working tree to a differently-named remote branch
|
|
169
|
+
* (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
|
|
170
|
+
* callers that print a branch-scoped URL reference where the commit actually
|
|
171
|
+
* landed — not the checked-out branch.
|
|
161
172
|
*/
|
|
162
|
-
export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
|
|
173
|
+
export declare function commitAndPush(repoPath: string, message: string, targetBranch?: string): Promise<CommitAndPushResult>;
|
|
163
174
|
/**
|
|
164
175
|
* Check if repo has uncommitted changes.
|
|
165
176
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -77,9 +77,18 @@ export function assertValidBranchName(branch) {
|
|
|
77
77
|
*
|
|
78
78
|
* Prefer this over `git.push(remote, branch)` whenever the branch comes from
|
|
79
79
|
* repo state rather than a hard-coded literal.
|
|
80
|
+
*
|
|
81
|
+
* Pass `targetBranch` to push the local `branch` to a differently-named remote
|
|
82
|
+
* branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
|
|
83
|
+
* working tree to a branch other than the checked-out one.
|
|
80
84
|
*/
|
|
81
|
-
export async function pushOrigin(git, branch) {
|
|
85
|
+
export async function pushOrigin(git, branch, targetBranch) {
|
|
82
86
|
assertValidBranchName(branch);
|
|
87
|
+
if (targetBranch && targetBranch !== branch) {
|
|
88
|
+
assertValidBranchName(targetBranch);
|
|
89
|
+
await git.raw(['push', '--', 'origin', `${branch}:${targetBranch}`]);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
83
92
|
await git.raw(['push', '--', 'origin', branch]);
|
|
84
93
|
}
|
|
85
94
|
/**
|
|
@@ -435,6 +444,16 @@ export async function getRemoteUrl(repoPath) {
|
|
|
435
444
|
return null;
|
|
436
445
|
}
|
|
437
446
|
}
|
|
447
|
+
/** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
|
|
448
|
+
export async function getCurrentBranch(repoPath) {
|
|
449
|
+
try {
|
|
450
|
+
const status = await simpleGit(repoPath).status();
|
|
451
|
+
return status.current || 'main';
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return 'main';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
438
457
|
/**
|
|
439
458
|
* Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
|
|
440
459
|
* same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
|
|
@@ -494,13 +513,23 @@ export async function checkGitHubRepoExists(owner, repo) {
|
|
|
494
513
|
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
495
514
|
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
496
515
|
* there is nothing to commit.
|
|
516
|
+
*
|
|
517
|
+
* `targetBranch` pushes the working tree to a differently-named remote branch
|
|
518
|
+
* (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
|
|
519
|
+
* callers that print a branch-scoped URL reference where the commit actually
|
|
520
|
+
* landed — not the checked-out branch.
|
|
497
521
|
*/
|
|
498
|
-
export async function commitAndPush(repoPath, message) {
|
|
522
|
+
export async function commitAndPush(repoPath, message, targetBranch) {
|
|
499
523
|
try {
|
|
500
524
|
const git = simpleGit(repoPath);
|
|
501
525
|
let status = await git.status();
|
|
502
526
|
const branch = status.current || 'main';
|
|
503
527
|
assertValidBranchName(branch);
|
|
528
|
+
if (targetBranch)
|
|
529
|
+
assertValidBranchName(targetBranch);
|
|
530
|
+
// The branch the commit ends up on remotely — the checked-out branch unless
|
|
531
|
+
// an explicit target was requested.
|
|
532
|
+
const pushedBranch = targetBranch || branch;
|
|
504
533
|
let committed = false;
|
|
505
534
|
if (status.files.length > 0) {
|
|
506
535
|
await git.add('-A');
|
|
@@ -509,7 +538,10 @@ export async function commitAndPush(repoPath, message) {
|
|
|
509
538
|
status = await git.status();
|
|
510
539
|
}
|
|
511
540
|
const ahead = status.ahead ?? 0;
|
|
512
|
-
|
|
541
|
+
// A same-branch push short-circuits when there is nothing new; a push to a
|
|
542
|
+
// different target branch must still run even from a clean, non-ahead tree,
|
|
543
|
+
// since the target may not carry these commits yet.
|
|
544
|
+
if (!committed && ahead === 0 && pushedBranch === branch) {
|
|
513
545
|
return {
|
|
514
546
|
success: true,
|
|
515
547
|
detail: 'already up to date',
|
|
@@ -521,12 +553,12 @@ export async function commitAndPush(repoPath, message) {
|
|
|
521
553
|
// Capture remote tip before push for a real ref range in the detail string.
|
|
522
554
|
let before = '';
|
|
523
555
|
try {
|
|
524
|
-
before = (await git.raw(['rev-parse', '--short=8', `origin/${
|
|
556
|
+
before = (await git.raw(['rev-parse', '--short=8', `origin/${pushedBranch}`])).trim();
|
|
525
557
|
}
|
|
526
558
|
catch {
|
|
527
559
|
/* origin/<branch> may not exist yet (first push) */
|
|
528
560
|
}
|
|
529
|
-
await pushOrigin(git, branch);
|
|
561
|
+
await pushOrigin(git, branch, targetBranch);
|
|
530
562
|
let after = '';
|
|
531
563
|
try {
|
|
532
564
|
after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
|
|
@@ -547,7 +579,7 @@ export async function commitAndPush(repoPath, message) {
|
|
|
547
579
|
return {
|
|
548
580
|
success: true,
|
|
549
581
|
detail,
|
|
550
|
-
branch,
|
|
582
|
+
branch: pushedBranch,
|
|
551
583
|
committed,
|
|
552
584
|
pushed: true,
|
|
553
585
|
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project Linear issue counts for the `agents projects status` card.
|
|
3
|
+
*
|
|
4
|
+
* When a project definition carries `linear.projectId` (set via
|
|
5
|
+
* `agents projects link <name> --linear`), the card shows one outcome line —
|
|
6
|
+
* `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
|
|
7
|
+
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
|
+
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
|
+
*
|
|
10
|
+
* The same fetch also yields the **next milestone** — the earliest-dated
|
|
11
|
+
* milestone with unfinished issues — because each issue node carries its
|
|
12
|
+
* `projectMilestone`. A percentage tells you how far along a project is; the
|
|
13
|
+
* milestone tells you what it is due to hit next, which is the thing a person
|
|
14
|
+
* actually plans around. Deriving it here costs no extra request.
|
|
15
|
+
*
|
|
16
|
+
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
17
|
+
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
18
|
+
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
19
|
+
* skips it (it's network). The API key resolves through the same chain the rest
|
|
20
|
+
* of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
|
|
21
|
+
* → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
|
|
22
|
+
*
|
|
23
|
+
* Paging is capped (10 × 250 issues) so a pathological project can't burn the
|
|
24
|
+
* budget; a capped fetch reports `truncated: true` and the card renders the
|
|
25
|
+
* total as a lower bound (`2500+ done`), never as the complete count.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The next checkpoint the project is working toward: the earliest-dated
|
|
29
|
+
* milestone that still has unfinished issues. A percentage says how far along
|
|
30
|
+
* the project is; this says what it is due to hit next.
|
|
31
|
+
*/
|
|
32
|
+
export interface LinearMilestone {
|
|
33
|
+
name: string;
|
|
34
|
+
/** `YYYY-MM-DD` as Linear stores it. Absent when the milestone has no date. */
|
|
35
|
+
targetDate?: string;
|
|
36
|
+
/** Issues in this milestone in a `completed`-type state. */
|
|
37
|
+
done: number;
|
|
38
|
+
/**
|
|
39
|
+
* Issues assigned to this milestone. Legitimately `0` — a milestone can be
|
|
40
|
+
* declared with a date long before any issue is filed under it (that is the
|
|
41
|
+
* state of every milestone in this repo's own Linear project), and such a
|
|
42
|
+
* milestone is still the next checkpoint. The card omits the fraction rather
|
|
43
|
+
* than printing a meaningless `0/0`.
|
|
44
|
+
*/
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
/** A milestone as the project declares it, independent of any issue. */
|
|
48
|
+
export interface LinearMilestoneNode {
|
|
49
|
+
id?: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
targetDate?: string | null;
|
|
52
|
+
}
|
|
53
|
+
/** The counts the card renders. `total` counts every issue in the project. */
|
|
54
|
+
export interface LinearProjectCounts {
|
|
55
|
+
/** Issues in a `completed`-type state. */
|
|
56
|
+
done: number;
|
|
57
|
+
/** All issues in the project (any state type, including canceled). */
|
|
58
|
+
total: number;
|
|
59
|
+
/** Issues in a `started`-type state. */
|
|
60
|
+
inProgress: number;
|
|
61
|
+
/**
|
|
62
|
+
* True when the page cap cut the fetch short — `total` is then a LOWER
|
|
63
|
+
* bound (rendered `2500+`), never presented as the complete count.
|
|
64
|
+
*/
|
|
65
|
+
truncated?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The next unfinished milestone, when the project has one. Derived from the
|
|
68
|
+
* SAME paged issue fetch as the counts — a milestone is only ever a grouping
|
|
69
|
+
* of these issues, so asking Linear again would spend a second round trip to
|
|
70
|
+
* learn what the first already said.
|
|
71
|
+
*/
|
|
72
|
+
nextMilestone?: LinearMilestone;
|
|
73
|
+
}
|
|
74
|
+
/** One issue node as the query selects it. */
|
|
75
|
+
export interface LinearIssueNode {
|
|
76
|
+
state?: {
|
|
77
|
+
type?: string;
|
|
78
|
+
} | null;
|
|
79
|
+
projectMilestone?: {
|
|
80
|
+
id?: string;
|
|
81
|
+
name?: string;
|
|
82
|
+
targetDate?: string | null;
|
|
83
|
+
} | null;
|
|
84
|
+
}
|
|
85
|
+
/** The GraphQL response shape this module consumes (recorded for the tests). */
|
|
86
|
+
export interface LinearIssuesResponse {
|
|
87
|
+
issues?: {
|
|
88
|
+
nodes?: LinearIssueNode[];
|
|
89
|
+
pageInfo?: {
|
|
90
|
+
hasNextPage?: boolean;
|
|
91
|
+
endCursor?: string | null;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/** Only the FIRST page asks for this — the milestone list does not paginate. */
|
|
95
|
+
project?: {
|
|
96
|
+
projectMilestones?: {
|
|
97
|
+
nodes?: LinearMilestoneNode[];
|
|
98
|
+
};
|
|
99
|
+
} | null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Pure mapping: a Linear issues response → card counts, grouping by state
|
|
103
|
+
* type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
|
|
104
|
+
* an issue with no state still counts toward `total`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function countsFromIssuesResponse(data: LinearIssuesResponse): LinearProjectCounts;
|
|
107
|
+
/**
|
|
108
|
+
* Pick the milestone the project is working toward next.
|
|
109
|
+
*
|
|
110
|
+
* The **declared** list is authoritative for which milestones exist, their
|
|
111
|
+
* names, and their dates; issues only supply progress. Deriving the list from
|
|
112
|
+
* issues instead looks tempting (it costs no extra request) and is wrong: a
|
|
113
|
+
* milestone with nothing filed under it yet would be invisible, and that is the
|
|
114
|
+
* common case — every milestone in this repo's own Linear project has zero
|
|
115
|
+
* issues assigned, so an issue-derived list showed nothing at all.
|
|
116
|
+
*
|
|
117
|
+
* Next = the earliest-dated milestone that is not finished. A milestone with no
|
|
118
|
+
* issues counts as unfinished (it is upcoming work, not completed work). Undated
|
|
119
|
+
* milestones sort last, ties break by declaration order, so the answer is stable.
|
|
120
|
+
*/
|
|
121
|
+
export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Fetch issue counts for one Linear project, paging `issues` filtered by
|
|
124
|
+
* project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
|
|
125
|
+
* any failure (no key, network, API error, abort) returns undefined so the
|
|
126
|
+
* card just omits the line. `fetchPage` is injectable for tests — the
|
|
127
|
+
* accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
|
|
128
|
+
*/
|
|
129
|
+
export declare function fetchLinearProjectCounts(projectId: string, fetchPage?: (projectId: string, after: string | undefined, signal: AbortSignal) => Promise<LinearIssuesResponse | undefined>): Promise<LinearProjectCounts | undefined>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project Linear issue counts for the `agents projects status` card.
|
|
3
|
+
*
|
|
4
|
+
* When a project definition carries `linear.projectId` (set via
|
|
5
|
+
* `agents projects link <name> --linear`), the card shows one outcome line —
|
|
6
|
+
* `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
|
|
7
|
+
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
|
+
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
|
+
*
|
|
10
|
+
* The same fetch also yields the **next milestone** — the earliest-dated
|
|
11
|
+
* milestone with unfinished issues — because each issue node carries its
|
|
12
|
+
* `projectMilestone`. A percentage tells you how far along a project is; the
|
|
13
|
+
* milestone tells you what it is due to hit next, which is the thing a person
|
|
14
|
+
* actually plans around. Deriving it here costs no extra request.
|
|
15
|
+
*
|
|
16
|
+
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
17
|
+
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
18
|
+
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
19
|
+
* skips it (it's network). The API key resolves through the same chain the rest
|
|
20
|
+
* of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
|
|
21
|
+
* → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
|
|
22
|
+
*
|
|
23
|
+
* Paging is capped (10 × 250 issues) so a pathological project can't burn the
|
|
24
|
+
* budget; a capped fetch reports `truncated: true` and the card renders the
|
|
25
|
+
* total as a lower bound (`2500+ done`), never as the complete count.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import * as os from 'os';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
import { resolveLinearApiKey } from './auto-dispatch-linear.js';
|
|
31
|
+
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
32
|
+
/** Overall budget across all pages — the card must never hang on Linear. */
|
|
33
|
+
const TIMEOUT_MS = 8_000;
|
|
34
|
+
const PAGE_SIZE = 250;
|
|
35
|
+
/** Hard page cap so a pathological project can't page forever within the budget. */
|
|
36
|
+
const MAX_PAGES = 10;
|
|
37
|
+
/**
|
|
38
|
+
* Pure mapping: a Linear issues response → card counts, grouping by state
|
|
39
|
+
* type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
|
|
40
|
+
* an issue with no state still counts toward `total`.
|
|
41
|
+
*/
|
|
42
|
+
export function countsFromIssuesResponse(data) {
|
|
43
|
+
const nodes = data.issues?.nodes ?? [];
|
|
44
|
+
let done = 0;
|
|
45
|
+
let inProgress = 0;
|
|
46
|
+
for (const n of nodes) {
|
|
47
|
+
const type = n?.state?.type;
|
|
48
|
+
if (type === 'completed')
|
|
49
|
+
done++;
|
|
50
|
+
else if (type === 'started')
|
|
51
|
+
inProgress++;
|
|
52
|
+
}
|
|
53
|
+
const counts = { done, total: nodes.length, inProgress };
|
|
54
|
+
const next = nextMilestone(data.project?.projectMilestones?.nodes ?? [], nodes);
|
|
55
|
+
if (next)
|
|
56
|
+
counts.nextMilestone = next;
|
|
57
|
+
return counts;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Pick the milestone the project is working toward next.
|
|
61
|
+
*
|
|
62
|
+
* The **declared** list is authoritative for which milestones exist, their
|
|
63
|
+
* names, and their dates; issues only supply progress. Deriving the list from
|
|
64
|
+
* issues instead looks tempting (it costs no extra request) and is wrong: a
|
|
65
|
+
* milestone with nothing filed under it yet would be invisible, and that is the
|
|
66
|
+
* common case — every milestone in this repo's own Linear project has zero
|
|
67
|
+
* issues assigned, so an issue-derived list showed nothing at all.
|
|
68
|
+
*
|
|
69
|
+
* Next = the earliest-dated milestone that is not finished. A milestone with no
|
|
70
|
+
* issues counts as unfinished (it is upcoming work, not completed work). Undated
|
|
71
|
+
* milestones sort last, ties break by declaration order, so the answer is stable.
|
|
72
|
+
*/
|
|
73
|
+
export function nextMilestone(declared, nodes) {
|
|
74
|
+
// Progress per milestone id, from whatever issues do carry one.
|
|
75
|
+
const progress = new Map();
|
|
76
|
+
for (const n of nodes) {
|
|
77
|
+
const id = n?.projectMilestone?.id;
|
|
78
|
+
if (!id)
|
|
79
|
+
continue;
|
|
80
|
+
const p = progress.get(id) ?? { done: 0, total: 0 };
|
|
81
|
+
p.total++;
|
|
82
|
+
if (n.state?.type === 'completed')
|
|
83
|
+
p.done++;
|
|
84
|
+
progress.set(id, p);
|
|
85
|
+
}
|
|
86
|
+
const candidates = declared
|
|
87
|
+
.map((d, order) => {
|
|
88
|
+
if (!d?.id || typeof d.name !== 'string' || !d.name)
|
|
89
|
+
return undefined;
|
|
90
|
+
const p = progress.get(d.id) ?? { done: 0, total: 0 };
|
|
91
|
+
const m = { name: d.name, done: p.done, total: p.total, order };
|
|
92
|
+
if (d.targetDate)
|
|
93
|
+
m.targetDate = d.targetDate;
|
|
94
|
+
return m;
|
|
95
|
+
})
|
|
96
|
+
.filter((m) => m !== undefined)
|
|
97
|
+
// total 0 means "declared, nothing filed yet" — unfinished, not done.
|
|
98
|
+
.filter((m) => m.total === 0 || m.done < m.total);
|
|
99
|
+
if (candidates.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
candidates.sort((a, b) => {
|
|
102
|
+
if (a.targetDate && b.targetDate)
|
|
103
|
+
return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
|
|
104
|
+
if (a.targetDate)
|
|
105
|
+
return -1;
|
|
106
|
+
if (b.targetDate)
|
|
107
|
+
return 1;
|
|
108
|
+
return a.order - b.order;
|
|
109
|
+
});
|
|
110
|
+
const { order: _order, ...m } = candidates[0];
|
|
111
|
+
return m;
|
|
112
|
+
}
|
|
113
|
+
/** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
|
|
114
|
+
function resolveApiKey() {
|
|
115
|
+
const fromChain = resolveLinearApiKey();
|
|
116
|
+
if (fromChain)
|
|
117
|
+
return fromChain;
|
|
118
|
+
try {
|
|
119
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.linear-cli', 'config.json'), 'utf8'));
|
|
120
|
+
return cfg.apiKey?.trim() || null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Fetch issue counts for one Linear project, paging `issues` filtered by
|
|
128
|
+
* project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
|
|
129
|
+
* any failure (no key, network, API error, abort) returns undefined so the
|
|
130
|
+
* card just omits the line. `fetchPage` is injectable for tests — the
|
|
131
|
+
* accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
|
|
132
|
+
*/
|
|
133
|
+
export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage) {
|
|
134
|
+
const ctrl = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
136
|
+
try {
|
|
137
|
+
const all = [];
|
|
138
|
+
// Declared on the project, not on its issues — only page 0 asks for it.
|
|
139
|
+
let declared = [];
|
|
140
|
+
let after;
|
|
141
|
+
let truncated = false;
|
|
142
|
+
for (let page = 0;; page++) {
|
|
143
|
+
const data = await fetchPage(projectId, after, ctrl.signal);
|
|
144
|
+
if (!data)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (page === 0)
|
|
147
|
+
declared = data.project?.projectMilestones?.nodes ?? [];
|
|
148
|
+
all.push(...(data.issues?.nodes ?? []));
|
|
149
|
+
const pi = data.issues?.pageInfo;
|
|
150
|
+
if (!pi?.hasNextPage || !pi.endCursor)
|
|
151
|
+
break;
|
|
152
|
+
if (page + 1 >= MAX_PAGES) {
|
|
153
|
+
// The cap cut the fetch short — total is a lower bound, say so.
|
|
154
|
+
truncated = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
after = pi.endCursor;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
...countsFromIssuesResponse({
|
|
161
|
+
issues: { nodes: all },
|
|
162
|
+
project: { projectMilestones: { nodes: declared } },
|
|
163
|
+
}),
|
|
164
|
+
...(truncated ? { truncated } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** One real GraphQL page; undefined on any HTTP/API-level failure. */
|
|
175
|
+
async function fetchLinearIssuesPage(projectId, after, signal) {
|
|
176
|
+
const apiKey = resolveApiKey();
|
|
177
|
+
if (!apiKey)
|
|
178
|
+
return undefined;
|
|
179
|
+
// The declared-milestone list rides along on the FIRST page only — it does
|
|
180
|
+
// not paginate, and re-requesting it per page would spend up to MAX_PAGES
|
|
181
|
+
// copies of the same answer.
|
|
182
|
+
const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
|
|
183
|
+
PAGE_SIZE +
|
|
184
|
+
', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
|
|
185
|
+
const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate } } }';
|
|
186
|
+
const first = after === undefined;
|
|
187
|
+
const res = await fetch(LINEAR_API, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
query: first
|
|
192
|
+
? `query($p:ID!, $pid:String!, $after:String){ ${issuesSelection} ${milestonesSelection} }`
|
|
193
|
+
: `query($p:ID!, $after:String){ ${issuesSelection} }`,
|
|
194
|
+
variables: first
|
|
195
|
+
? { p: projectId, pid: projectId, after: null }
|
|
196
|
+
: { p: projectId, after },
|
|
197
|
+
}),
|
|
198
|
+
signal,
|
|
199
|
+
});
|
|
200
|
+
if (!res.ok)
|
|
201
|
+
return undefined;
|
|
202
|
+
const json = (await res.json());
|
|
203
|
+
if (json.errors?.length || !json.data)
|
|
204
|
+
return undefined;
|
|
205
|
+
return json.data;
|
|
206
|
+
}
|