@ivotoby/postgram-cli 1.38.3 → 1.40.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.
@@ -0,0 +1,298 @@
1
+ // cli/src/tracking/feature-commands.ts: pgm feature, pgm machine, pgm repo.
2
+ import { existsSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import { AppError, ErrorCode } from '../errors.js';
5
+ import { parseCommaList, shortId } from '../shared.js';
6
+ import { runTrackingCommand } from './api.js';
7
+ import { UsageError, usageExit, writeMachineConfig } from './config.js';
8
+ import { readBootId, removeMarker, resolveLocalWorkspace, writeMarker } from './local.js';
9
+ import { formatSync, runFeatureSync, runGhPrState } from './feature-sync.js';
10
+ import { FEATURE_STAGES } from './types.js';
11
+ export async function runFeatureAdd(title, options, deps) {
12
+ const tags = parseCommaList(options.tags);
13
+ const response = await deps.client.createFeature({
14
+ content: title,
15
+ ...(tags !== undefined ? { tags } : {}),
16
+ ...(options.visibility !== undefined ? { visibility: options.visibility } : {})
17
+ });
18
+ return deps.json ? response : [response.entity.id];
19
+ }
20
+ export async function runFeatureStage(id, stage, deps) {
21
+ if (!FEATURE_STAGES.includes(stage)) {
22
+ throw new UsageError(`invalid stage '${stage}'. Use one of: ${FEATURE_STAGES.join(', ')}`);
23
+ }
24
+ const response = await deps.client.setStage(id, stage);
25
+ return deps.json
26
+ ? response
27
+ : [`${shortId(response.entity.id)} ${response.previous_stage ?? '-'} → ${stage}`];
28
+ }
29
+ const FULL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ const SHORT_HEX = /^[0-9a-f]{8}$/i;
31
+ async function resolveTaskId(id, deps) {
32
+ if (FULL_UUID.test(id)) {
33
+ return id.toLowerCase();
34
+ }
35
+ if (!SHORT_HEX.test(id)) {
36
+ throw new UsageError('feature id must be a full UUID or an 8-hex short id');
37
+ }
38
+ const prefix = id.toLowerCase();
39
+ const matches = [];
40
+ for (let offset = 0;; offset += 100) {
41
+ const page = await deps.pgm.listTasks({ limit: 100, offset });
42
+ for (const item of page.items) {
43
+ if (item.id.startsWith(prefix)) {
44
+ matches.push(item.id);
45
+ }
46
+ }
47
+ if (page.items.length < 100) {
48
+ break;
49
+ }
50
+ }
51
+ if (matches.length > 1) {
52
+ throw new AppError(ErrorCode.CONFLICT, `feature id ${id} is ambiguous`);
53
+ }
54
+ const [match] = matches;
55
+ if (match === undefined) {
56
+ throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
57
+ }
58
+ return match;
59
+ }
60
+ /**
61
+ * Marks the feature's task done. `pgm where` already hides done tasks unless
62
+ * --all is given, so a feature needs no stage of its own for this.
63
+ */
64
+ export async function runFeatureDone(id, deps) {
65
+ const taskId = await resolveTaskId(id, deps);
66
+ const current = await deps.pgm.recallEntity(taskId);
67
+ if (current.entity.status === 'done') {
68
+ return deps.json
69
+ ? { id: taskId, status: 'done', changed: false }
70
+ : [`${shortId(taskId)} already done`];
71
+ }
72
+ const completed = await deps.pgm.completeTask(taskId, current.entity.version);
73
+ return deps.json
74
+ ? { id: taskId, status: completed.entity.status, changed: true }
75
+ : [`${shortId(taskId)} done`];
76
+ }
77
+ const PR_STATES = ['open', 'merged', 'closed'];
78
+ export async function runFeaturePr(id, url, options, deps) {
79
+ const state = options.state ?? 'open';
80
+ if (!PR_STATES.includes(state)) {
81
+ throw new UsageError(`invalid --state '${state}'. Use one of: ${PR_STATES.join(', ')}`);
82
+ }
83
+ const response = await deps.client.addPullRequest(id, {
84
+ url,
85
+ state: state
86
+ });
87
+ return deps.json
88
+ ? response
89
+ : [`${shortId(response.entity.id)} PR ${state}, stage ${response.to}`];
90
+ }
91
+ export async function runFeatureLink(id, options, deps) {
92
+ if (options.workspace !== undefined) {
93
+ const patched = await deps.client.patchWorkspace(options.workspace, {
94
+ feature_id: id
95
+ });
96
+ const workspace = patched.workspace;
97
+ if (workspace.machine === deps.config.alias && deps.exists(workspace.path)) {
98
+ if (id !== null) {
99
+ await deps.writeMarker(workspace.path, workspace.feature_id ?? id);
100
+ }
101
+ else {
102
+ await deps.removeMarker(workspace.path);
103
+ }
104
+ }
105
+ else {
106
+ deps.warn(`warning: ${workspace.machine} may have a feature marker; its next registration will restore the marker's link`);
107
+ }
108
+ return deps.json
109
+ ? patched
110
+ : [
111
+ id !== null
112
+ ? `linked ${workspace.path} → ${shortId(id)}`
113
+ : `unlinked ${workspace.path}`
114
+ ];
115
+ }
116
+ const target = options.path ?? deps.cwd;
117
+ const local = await deps.resolveWorkspace(target);
118
+ if (local.kind === 'timeout') {
119
+ throw new AppError(ErrorCode.INTERNAL, 'git timeout');
120
+ }
121
+ const looked = await deps.client.lookup(deps.config.alias, local.path);
122
+ let workspaceId;
123
+ if (looked.workspace !== null && looked.workspace.path === local.path) {
124
+ workspaceId = looked.workspace.id;
125
+ }
126
+ else {
127
+ const report = await deps.client.report({
128
+ source: 'hook',
129
+ machine: {
130
+ alias: deps.config.alias,
131
+ hostname: deps.hostname,
132
+ boot_id: await deps.bootId()
133
+ },
134
+ workspace: local.workspace
135
+ });
136
+ if (report.workspace === null) {
137
+ throw new AppError(ErrorCode.INTERNAL, 'workspace registration returned no workspace');
138
+ }
139
+ workspaceId = report.workspace.id;
140
+ }
141
+ const patched = await deps.client.patchWorkspace(workspaceId, {
142
+ feature_id: id
143
+ });
144
+ if (id !== null) {
145
+ await deps.writeMarker(local.path, patched.workspace.feature_id ?? id);
146
+ }
147
+ else {
148
+ await deps.removeMarker(local.path);
149
+ }
150
+ return deps.json
151
+ ? patched
152
+ : [
153
+ id !== null
154
+ ? `linked ${local.path} → ${shortId(id)}`
155
+ : `unlinked ${local.path}`
156
+ ];
157
+ }
158
+ export async function runMachineSet(options, deps) {
159
+ const written = await deps.writeConfig({
160
+ ...(options.alias !== undefined ? { alias: options.alias } : {}),
161
+ ...(options.connect !== undefined ? { connectTarget: options.connect } : {}),
162
+ homeDir: deps.homeDir
163
+ });
164
+ if (written.previousAlias !== null && written.previousAlias !== written.alias) {
165
+ deps.warn(`warning: history stays under the old alias '${written.previousAlias}'`);
166
+ }
167
+ await deps.client.report({
168
+ source: 'hook',
169
+ machine: {
170
+ alias: written.alias,
171
+ hostname: deps.hostname,
172
+ boot_id: await deps.bootId()
173
+ }
174
+ });
175
+ const result = await deps.client.putMachine(written.alias, written.connectTarget);
176
+ return deps.json
177
+ ? result
178
+ : [`machine ${written.alias} → ${written.connectTarget}`];
179
+ }
180
+ export async function runMachineList(deps) {
181
+ const result = await deps.client.listMachines();
182
+ return deps.json
183
+ ? result
184
+ : result.machines.map((machine) => `${machine.alias} ${machine.connect_target ?? machine.alias} ${machine.last_seen_at}`);
185
+ }
186
+ export async function runRepoSet(remote, options, deps) {
187
+ if (options.releaseOnMerge !== 'true' && options.releaseOnMerge !== 'false') {
188
+ throw new UsageError(`--release-on-merge must be 'true' or 'false'`);
189
+ }
190
+ const releaseOnMerge = options.releaseOnMerge === 'true';
191
+ const result = await deps.client.putRepository(remote, releaseOnMerge);
192
+ return deps.json ? result : [`${remote} release_on_merge=${releaseOnMerge}`];
193
+ }
194
+ function featureDeps(context) {
195
+ return {
196
+ client: context.client,
197
+ config: context.config,
198
+ json: context.json,
199
+ cwd: process.cwd(),
200
+ hostname: os.hostname(),
201
+ homeDir: context.homeDir,
202
+ bootId: readBootId,
203
+ resolveWorkspace: (cwd) => resolveLocalWorkspace(cwd),
204
+ writeMarker: (worktree, featureId) => writeMarker(worktree, featureId, new Date()),
205
+ removeMarker,
206
+ exists: existsSync,
207
+ warn: (line) => process.stderr.write(`${line}\n`)
208
+ };
209
+ }
210
+ export function registerFeatureCommands(program) {
211
+ const feature = usageExit(program
212
+ .command('feature')
213
+ .description('Manage features'));
214
+ usageExit(feature
215
+ .command('add')
216
+ .description('Create a feature')
217
+ .argument('<title>', 'feature title')
218
+ .option('--tags <tags>', 'comma-separated tags')
219
+ .option('--visibility <visibility>', 'personal, work or shared')).action(async (title, options, command) => {
220
+ await runTrackingCommand(command, (context) => runFeatureAdd(title, options, featureDeps(context)));
221
+ });
222
+ usageExit(feature
223
+ .command('stage')
224
+ .description('Set a feature stage')
225
+ .argument('<id>', 'feature id (UUID or 8-hex)')
226
+ .argument('<stage>', `one of: ${FEATURE_STAGES.join(', ')}`)).action(async (id, stage, _options, command) => {
227
+ await runTrackingCommand(command, (context) => runFeatureStage(id, stage, featureDeps(context)));
228
+ });
229
+ usageExit(feature
230
+ .command('pr')
231
+ .description('Attach a pull request to a feature')
232
+ .argument('<id>', 'feature id (UUID or 8-hex)')
233
+ .argument('<url>', 'pull request URL')
234
+ .option('--state <state>', 'open, merged or closed', 'open')).action(async (id, url, options, command) => {
235
+ await runTrackingCommand(command, (context) => runFeaturePr(id, url, options, featureDeps(context)));
236
+ });
237
+ usageExit(feature
238
+ .command('done')
239
+ .description('Mark a feature done so pgm where hides it (pgm where --all shows it)')
240
+ .argument('<id>', 'feature id (UUID or 8-hex)')).action(async (id, _options, command) => {
241
+ await runTrackingCommand(command, (context) => runFeatureDone(id, { pgm: context.pgm, json: context.json }));
242
+ });
243
+ usageExit(feature
244
+ .command('sync')
245
+ .description('Update PR states from GitHub (needs gh) and apply the stage rules')
246
+ .argument('[id]', 'only this feature (UUID or 8-hex)')).action(async (id, _options, command) => {
247
+ await runTrackingCommand(command, async (context) => {
248
+ const result = await runFeatureSync(id, {
249
+ client: context.client,
250
+ gh: (url) => runGhPrState(url)
251
+ });
252
+ if (result.errors.length > 0) {
253
+ process.exitCode = 1;
254
+ }
255
+ return context.json ? result : formatSync(result);
256
+ });
257
+ });
258
+ usageExit(feature
259
+ .command('link')
260
+ .description('Link a workspace to a feature')
261
+ .argument('<id>', 'feature id (UUID or 8-hex)')
262
+ .option('--path <dir>', 'workspace directory (default: cwd)')
263
+ .option('--workspace <workspace-uuid>', 'link by workspace id instead')).action(async (id, options, command) => {
264
+ await runTrackingCommand(command, (context) => runFeatureLink(id, options, featureDeps(context)));
265
+ });
266
+ usageExit(feature
267
+ .command('unlink')
268
+ .description('Unlink a workspace from its feature')
269
+ .option('--path <dir>', 'workspace directory (default: cwd)')
270
+ .option('--workspace <workspace-uuid>', 'unlink by workspace id instead')).action(async (options, command) => {
271
+ await runTrackingCommand(command, (context) => runFeatureLink(null, options, featureDeps(context)));
272
+ });
273
+ const machine = usageExit(program
274
+ .command('machine')
275
+ .description('Machine settings'));
276
+ usageExit(machine
277
+ .command('set')
278
+ .description('Write ~/.pgmrc and register the machine')
279
+ .option('--alias <alias>', 'machine alias')
280
+ .option('--connect <target>', 'connect target')).action(async (options, command) => {
281
+ await runTrackingCommand(command, (context) => runMachineSet(options, { ...featureDeps(context), writeConfig: writeMachineConfig }));
282
+ });
283
+ usageExit(machine
284
+ .command('list')
285
+ .description('List machines')).action(async (_options, command) => {
286
+ await runTrackingCommand(command, (context) => runMachineList(featureDeps(context)));
287
+ });
288
+ const repo = usageExit(program
289
+ .command('repo')
290
+ .description('Repository settings'));
291
+ usageExit(repo
292
+ .command('set')
293
+ .description('Set repository settings')
294
+ .argument('<remote>', 'repository remote')
295
+ .option('--release-on-merge <bool>', 'true or false')).action(async (remote, options, command) => {
296
+ await runTrackingCommand(command, (context) => runRepoSet(remote, options, featureDeps(context)));
297
+ });
298
+ }
@@ -0,0 +1,151 @@
1
+ // cli/src/tracking/feature-sync.ts: pgm feature sync — follow GitHub PR state.
2
+ import { execFile } from 'node:child_process';
3
+ import { AppError, ErrorCode } from '../errors.js';
4
+ import { shortId } from '../shared.js';
5
+ import { UsageError } from './config.js';
6
+ const GH_STATES = {
7
+ OPEN: 'open',
8
+ MERGED: 'merged',
9
+ CLOSED: 'closed'
10
+ };
11
+ export function parseGhState(stdout) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(stdout);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
20
+ return null;
21
+ }
22
+ const state = parsed.state;
23
+ return typeof state === 'string' ? (GH_STATES[state] ?? null) : null;
24
+ }
25
+ export function classifyGhFailure(error) {
26
+ const stderr = error.stderr ?? '';
27
+ if (error.code === 'ENOENT') {
28
+ return { ok: false, kind: 'missing', message: 'gh is not installed' };
29
+ }
30
+ if (/gh auth login|not logged in|not logged into|authentication/i.test(stderr)) {
31
+ return { ok: false, kind: 'unauthenticated', message: 'gh is not logged in' };
32
+ }
33
+ const first = stderr.split('\n').find((line) => line.trim() !== '');
34
+ return { ok: false, kind: 'error', message: first?.trim() ?? 'gh failed' };
35
+ }
36
+ const defaultExec = (cmd, args) => new Promise((resolve, reject) => {
37
+ execFile(cmd, args, { timeout: 20_000 }, (error, stdout, stderr) => {
38
+ if (error !== null) {
39
+ reject(Object.assign(error, { stderr }));
40
+ }
41
+ else {
42
+ resolve({ stdout });
43
+ }
44
+ });
45
+ });
46
+ export async function runGhPrState(url, exec = defaultExec) {
47
+ let stdout;
48
+ try {
49
+ ({ stdout } = await exec('gh', ['pr', 'view', url, '--json', 'state']));
50
+ }
51
+ catch (error) {
52
+ const failure = error;
53
+ return classifyGhFailure({ code: failure.code, stderr: failure.stderr });
54
+ }
55
+ const state = parseGhState(stdout);
56
+ return state === null
57
+ ? { ok: false, kind: 'error', message: 'unexpected output from gh pr view' }
58
+ : { ok: true, state };
59
+ }
60
+ function collectFeatures(response) {
61
+ const features = [];
62
+ for (const group of response.groups) {
63
+ if ('features' in group) {
64
+ features.push(...group.features);
65
+ }
66
+ }
67
+ return features;
68
+ }
69
+ /**
70
+ * Asks gh for the state of every PR that is not merged yet and sends changes
71
+ * through the feature PR endpoint, so the server's derived-stage rules apply.
72
+ * A merged PR is final and is not asked again.
73
+ */
74
+ export async function runFeatureSync(id, deps) {
75
+ const response = await deps.client.where(id === undefined
76
+ ? { view: 'stage' }
77
+ : { view: 'stage', includeReleased: true, includeDone: true });
78
+ let features = collectFeatures(response);
79
+ if (id !== undefined) {
80
+ const wanted = id.toLowerCase();
81
+ features = features.filter((feature) => feature.id === wanted || feature.id.startsWith(wanted));
82
+ if (features.length === 0) {
83
+ throw new AppError(ErrorCode.NOT_FOUND, `no feature matches ${id}`);
84
+ }
85
+ }
86
+ const result = { checked: 0, unchanged: 0, changed: [], errors: [] };
87
+ const answers = new Map();
88
+ for (const feature of features) {
89
+ for (const pull of feature.pull_requests) {
90
+ if (pull.state === 'merged') {
91
+ continue;
92
+ }
93
+ let answer = answers.get(pull.url);
94
+ if (answer === undefined) {
95
+ answer = await deps.gh(pull.url);
96
+ answers.set(pull.url, answer);
97
+ }
98
+ if (!answer.ok && answer.kind === 'missing') {
99
+ throw new UsageError('gh (GitHub CLI) was not found on PATH. Install it from https://cli.github.com to use `pgm feature sync`.');
100
+ }
101
+ if (!answer.ok && answer.kind === 'unauthenticated') {
102
+ throw new UsageError('gh is not logged in. Run `gh auth login`, then retry `pgm feature sync`.');
103
+ }
104
+ result.checked += 1;
105
+ if (!answer.ok) {
106
+ result.errors.push({
107
+ feature_id: feature.id,
108
+ url: pull.url,
109
+ error: answer.message
110
+ });
111
+ continue;
112
+ }
113
+ if (answer.state === pull.state) {
114
+ result.unchanged += 1;
115
+ continue;
116
+ }
117
+ try {
118
+ const updated = await deps.client.addPullRequest(feature.id, {
119
+ url: pull.url,
120
+ state: answer.state
121
+ });
122
+ result.changed.push({
123
+ feature_id: feature.id,
124
+ url: pull.url,
125
+ from: pull.state,
126
+ to: answer.state,
127
+ stage_from: updated.from,
128
+ stage_to: updated.to
129
+ });
130
+ }
131
+ catch (error) {
132
+ result.errors.push({
133
+ feature_id: feature.id,
134
+ url: pull.url,
135
+ error: error instanceof Error ? error.message : 'update failed'
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ export function formatSync(result) {
143
+ const lines = result.changed.map((change) => `${shortId(change.feature_id)} ${change.url} ${change.from} → ${change.to}${change.stage_from !== change.stage_to
144
+ ? `, stage ${change.stage_from} → ${change.stage_to}`
145
+ : ''}`);
146
+ for (const error of result.errors) {
147
+ lines.push(`error ${shortId(error.feature_id)} ${error.url}: ${error.error}`);
148
+ }
149
+ lines.push(`checked ${result.checked} pull requests: ${result.changed.length} changed, ${result.unchanged} unchanged, ${result.errors.length} failed`);
150
+ return lines;
151
+ }