deploylog 0.3.0 → 0.4.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/README.md +109 -30
- package/dist/api.d.ts +59 -1
- package/dist/api.js +43 -2
- package/dist/editor.d.ts +23 -0
- package/dist/editor.js +46 -0
- package/dist/entry-commands.d.ts +111 -0
- package/dist/entry-commands.js +157 -0
- package/dist/index.js +479 -108
- package/dist/init.d.ts +38 -0
- package/dist/init.js +86 -0
- package/dist/open.d.ts +33 -0
- package/dist/open.js +51 -0
- package/dist/project-config.d.ts +15 -5
- package/dist/project-config.js +43 -7
- package/dist/push.d.ts +71 -0
- package/dist/push.js +149 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.js +16 -0
- package/package.json +1 -1
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Project } from './api.js';
|
|
2
|
+
export interface InitOptions {
|
|
3
|
+
project?: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
force?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export type InitResult = {
|
|
8
|
+
kind: 'written';
|
|
9
|
+
path: string;
|
|
10
|
+
project: string;
|
|
11
|
+
defaultType?: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'exists';
|
|
14
|
+
message: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'cancelled';
|
|
17
|
+
message: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'missing-fields';
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export interface InitDeps {
|
|
23
|
+
api: {
|
|
24
|
+
listProjects(): Promise<Project[]>;
|
|
25
|
+
};
|
|
26
|
+
exists(path: string): boolean;
|
|
27
|
+
write(path: string, content: string): void;
|
|
28
|
+
/** Numbered pick from a list; resolves null when the user bails. */
|
|
29
|
+
select(question: string, choices: string[]): Promise<string | null>;
|
|
30
|
+
isTTY: boolean;
|
|
31
|
+
cwd(): string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Scaffold .deploylog.yml in the current directory. Keys must match what
|
|
35
|
+
* readProjectConfig() consumes: `project` and `default_type`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runInit(opts: InitOptions, deps?: InitDeps): Promise<InitResult>;
|
|
38
|
+
export declare const defaultInitDeps: InitDeps;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listProjects } from './api.js';
|
|
4
|
+
const ENTRY_TYPES = ['feature', 'fix', 'improvement', 'breaking', 'announcement'];
|
|
5
|
+
/**
|
|
6
|
+
* Scaffold .deploylog.yml in the current directory. Keys must match what
|
|
7
|
+
* readProjectConfig() consumes: `project` and `default_type`.
|
|
8
|
+
*/
|
|
9
|
+
export async function runInit(opts, deps = defaultInitDeps) {
|
|
10
|
+
const path = join(deps.cwd(), '.deploylog.yml');
|
|
11
|
+
if (deps.exists(path) && !opts.force) {
|
|
12
|
+
return {
|
|
13
|
+
kind: 'exists',
|
|
14
|
+
message: `${path} already exists. Pass --force to overwrite it.`,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (opts.type !== undefined && !ENTRY_TYPES.includes(opts.type)) {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'missing-fields',
|
|
20
|
+
message: `Unknown default type '${opts.type}'. One of: ${ENTRY_TYPES.join(', ')}.`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const projects = await deps.api.listProjects();
|
|
24
|
+
if (projects.length === 0) {
|
|
25
|
+
return {
|
|
26
|
+
kind: 'missing-fields',
|
|
27
|
+
message: 'No projects in your organization yet. Create one first: deploylog projects create <name>',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
let projectSlug = opts.project;
|
|
31
|
+
if (projectSlug !== undefined) {
|
|
32
|
+
if (!projects.some((p) => p.slug === projectSlug)) {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'missing-fields',
|
|
35
|
+
message: `Project '${projectSlug}' not found in your organization.\n` +
|
|
36
|
+
`Available: ${projects.map((p) => p.slug).join(', ')}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (projects.length === 1) {
|
|
41
|
+
projectSlug = projects[0].slug;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (!deps.isTTY) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'missing-fields',
|
|
47
|
+
message: 'Multiple projects found. Pass --project <slug> in non-interactive mode.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const picked = await deps.select('Which project should this directory push to?', projects.map((p) => `${p.slug} (${p.name})`));
|
|
51
|
+
if (picked === null) {
|
|
52
|
+
return { kind: 'cancelled', message: 'Cancelled. No config written.' };
|
|
53
|
+
}
|
|
54
|
+
projectSlug = picked.split(' ')[0];
|
|
55
|
+
}
|
|
56
|
+
const lines = [`project: ${projectSlug}`];
|
|
57
|
+
if (opts.type)
|
|
58
|
+
lines.push(`default_type: ${opts.type}`);
|
|
59
|
+
deps.write(path, lines.join('\n') + '\n');
|
|
60
|
+
const result = { kind: 'written', path, project: projectSlug };
|
|
61
|
+
if (opts.type)
|
|
62
|
+
return { ...result, defaultType: opts.type };
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
async function selectPrompt(question, choices) {
|
|
66
|
+
const { createInterface } = await import('node:readline');
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
console.log(question);
|
|
69
|
+
choices.forEach((c, i) => console.log(` ${i + 1}) ${c}`));
|
|
70
|
+
const answer = await new Promise((resolve) => {
|
|
71
|
+
rl.question(`Choose [1-${choices.length}]: `, resolve);
|
|
72
|
+
});
|
|
73
|
+
rl.close();
|
|
74
|
+
const idx = parseInt(answer.trim(), 10);
|
|
75
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > choices.length)
|
|
76
|
+
return null;
|
|
77
|
+
return choices[idx - 1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
export const defaultInitDeps = {
|
|
80
|
+
api: { listProjects },
|
|
81
|
+
exists: existsSync,
|
|
82
|
+
write: (path, content) => writeFileSync(path, content, 'utf8'),
|
|
83
|
+
select: selectPrompt,
|
|
84
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
85
|
+
cwd: () => process.cwd(),
|
|
86
|
+
};
|
package/dist/open.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type EntryDetail } from './api.js';
|
|
2
|
+
import { type ProjectConfig } from './project-config.js';
|
|
3
|
+
export interface OpenOptions {
|
|
4
|
+
ref?: string;
|
|
5
|
+
project?: string;
|
|
6
|
+
}
|
|
7
|
+
export type OpenResult = {
|
|
8
|
+
kind: 'opened';
|
|
9
|
+
url: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'printed';
|
|
12
|
+
url: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'missing-fields';
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
export interface OpenDeps {
|
|
18
|
+
api: {
|
|
19
|
+
getEntry(id: string): Promise<EntryDetail>;
|
|
20
|
+
};
|
|
21
|
+
readProjectConfig(): ProjectConfig | null;
|
|
22
|
+
/** Try to open the URL in a browser; false when there's nothing to open with. */
|
|
23
|
+
launch(url: string): boolean;
|
|
24
|
+
baseUrl(): string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the public URL (changelog page, or a single entry's page) and open it.
|
|
28
|
+
* A slug ref goes straight into the URL — public pages don't need auth. Only a
|
|
29
|
+
* uuid ref costs an API call (to look up the slug the public URL wants).
|
|
30
|
+
* Headless environments get the URL printed instead of a hung browser spawn.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runOpen(opts: OpenOptions, deps?: OpenDeps): Promise<OpenResult>;
|
|
33
|
+
export declare const defaultOpenDeps: OpenDeps;
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { getEntry } from './api.js';
|
|
3
|
+
import { getApiUrl } from './config.js';
|
|
4
|
+
import { readProjectConfig } from './project-config.js';
|
|
5
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
/**
|
|
7
|
+
* Build the public URL (changelog page, or a single entry's page) and open it.
|
|
8
|
+
* A slug ref goes straight into the URL — public pages don't need auth. Only a
|
|
9
|
+
* uuid ref costs an API call (to look up the slug the public URL wants).
|
|
10
|
+
* Headless environments get the URL printed instead of a hung browser spawn.
|
|
11
|
+
*/
|
|
12
|
+
export async function runOpen(opts, deps = defaultOpenDeps) {
|
|
13
|
+
const projectConfig = deps.readProjectConfig();
|
|
14
|
+
const project = opts.project ?? projectConfig?.project;
|
|
15
|
+
if (!project) {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'missing-fields',
|
|
18
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const base = deps.baseUrl();
|
|
22
|
+
let url = `${base}/p/${project}/changelog`;
|
|
23
|
+
if (opts.ref) {
|
|
24
|
+
const entrySlug = UUID_RE.test(opts.ref)
|
|
25
|
+
? (await deps.api.getEntry(opts.ref)).slug
|
|
26
|
+
: opts.ref;
|
|
27
|
+
url = `${base}/p/${project}/c/${entrySlug}`;
|
|
28
|
+
}
|
|
29
|
+
if (deps.launch(url)) {
|
|
30
|
+
return { kind: 'opened', url };
|
|
31
|
+
}
|
|
32
|
+
return { kind: 'printed', url };
|
|
33
|
+
}
|
|
34
|
+
function launchBrowser(url) {
|
|
35
|
+
// No display server → nothing to open with; the caller prints the URL.
|
|
36
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
40
|
+
const child = spawn(opener, [url], { stdio: 'ignore', detached: true });
|
|
41
|
+
// ENOENT arrives async — swallow it; the URL is printed either way.
|
|
42
|
+
child.on('error', () => { });
|
|
43
|
+
child.unref();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export const defaultOpenDeps = {
|
|
47
|
+
api: { getEntry },
|
|
48
|
+
readProjectConfig: () => readProjectConfig(),
|
|
49
|
+
launch: launchBrowser,
|
|
50
|
+
baseUrl: () => getApiUrl(),
|
|
51
|
+
};
|
package/dist/project-config.d.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
interface ProjectConfig {
|
|
1
|
+
export interface ProjectConfig {
|
|
2
2
|
project?: string;
|
|
3
3
|
default_type?: string;
|
|
4
4
|
}
|
|
5
|
+
export declare class ProjectConfigError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
5
8
|
/**
|
|
6
|
-
* Read .deploylog.yml from
|
|
7
|
-
*
|
|
9
|
+
* Read .deploylog.yml from `startDir` (or its parents).
|
|
10
|
+
*
|
|
11
|
+
* The walk distinguishes two failures the old code conflated:
|
|
12
|
+
* - not found at this level → keep walking up (returns null if none found)
|
|
13
|
+
* - present but malformed → STOP and throw, so a broken config never
|
|
14
|
+
* silently falls through to a parent directory's config (a wrong-project
|
|
15
|
+
* push) or a misleading "No project specified". (A3)
|
|
16
|
+
*
|
|
17
|
+
* `startDir` is injected (not read from `process.cwd()` here) so the walk is
|
|
18
|
+
* testable without `chdir`.
|
|
8
19
|
*/
|
|
9
|
-
export declare function readProjectConfig(): ProjectConfig | null;
|
|
10
|
-
export {};
|
|
20
|
+
export declare function readProjectConfig(startDir?: string): ProjectConfig | null;
|
package/dist/project-config.js
CHANGED
|
@@ -1,20 +1,56 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
|
+
export class ProjectConfigError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'ProjectConfigError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
/**
|
|
5
|
-
* Read .deploylog.yml from
|
|
6
|
-
*
|
|
11
|
+
* Read .deploylog.yml from `startDir` (or its parents).
|
|
12
|
+
*
|
|
13
|
+
* The walk distinguishes two failures the old code conflated:
|
|
14
|
+
* - not found at this level → keep walking up (returns null if none found)
|
|
15
|
+
* - present but malformed → STOP and throw, so a broken config never
|
|
16
|
+
* silently falls through to a parent directory's config (a wrong-project
|
|
17
|
+
* push) or a misleading "No project specified". (A3)
|
|
18
|
+
*
|
|
19
|
+
* `startDir` is injected (not read from `process.cwd()` here) so the walk is
|
|
20
|
+
* testable without `chdir`.
|
|
7
21
|
*/
|
|
8
|
-
export function readProjectConfig() {
|
|
9
|
-
let dir =
|
|
22
|
+
export function readProjectConfig(startDir = process.cwd()) {
|
|
23
|
+
let dir = startDir;
|
|
10
24
|
while (true) {
|
|
11
25
|
const filePath = resolve(dir, '.deploylog.yml');
|
|
26
|
+
let content = null;
|
|
12
27
|
try {
|
|
13
|
-
|
|
14
|
-
return parse(content);
|
|
28
|
+
content = readFileSync(filePath, 'utf-8');
|
|
15
29
|
}
|
|
16
30
|
catch {
|
|
17
|
-
//
|
|
31
|
+
// Not found (or unreadable) at this level — walk up.
|
|
32
|
+
content = null;
|
|
33
|
+
}
|
|
34
|
+
if (content !== null) {
|
|
35
|
+
// A file exists here: this level is authoritative. Any parse/shape
|
|
36
|
+
// failure stops the walk — we never adopt a parent's config behind a
|
|
37
|
+
// broken file.
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = parse(content);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new ProjectConfigError(`Invalid YAML in ${filePath}: ${detail}`);
|
|
45
|
+
}
|
|
46
|
+
// An empty file parses to null/undefined — treat as a present-but-empty
|
|
47
|
+
// config (stops the walk), not "not found".
|
|
48
|
+
if (parsed === null || parsed === undefined)
|
|
49
|
+
return {};
|
|
50
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
51
|
+
throw new ProjectConfigError(`Invalid .deploylog.yml at ${filePath}: expected a mapping like "project: my-app".`);
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
18
54
|
}
|
|
19
55
|
const parent = resolve(dir, '..');
|
|
20
56
|
if (parent === dir)
|
package/dist/push.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type CommitSummary } from './git.js';
|
|
2
|
+
import { type Entry, type CreateEntryInput, type SummarizeInput, type SummarizeResponse, type AiSummary } from './api.js';
|
|
3
|
+
import { type ProjectConfig } from './project-config.js';
|
|
4
|
+
/** Raw commander options for `push`, aliases included (reconciled inside runPush). */
|
|
5
|
+
export interface PushOptions {
|
|
6
|
+
title?: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
project?: string;
|
|
9
|
+
type?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
publish?: boolean;
|
|
12
|
+
draft?: boolean;
|
|
13
|
+
fromGit?: boolean;
|
|
14
|
+
git?: boolean;
|
|
15
|
+
aiSummarize?: boolean;
|
|
16
|
+
ai?: boolean;
|
|
17
|
+
yes?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Outcome of a push attempt. `created` carries the entry; every other kind is a
|
|
21
|
+
* typed refusal that replaces a `process.exit(1)` — the adapter decides how to
|
|
22
|
+
* print and what exit code to use.
|
|
23
|
+
*/
|
|
24
|
+
export type PushResult = {
|
|
25
|
+
kind: 'created';
|
|
26
|
+
entry: Entry;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'no-commits';
|
|
29
|
+
message: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'no-ai-source';
|
|
32
|
+
message: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'missing-fields';
|
|
35
|
+
message: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'cancelled';
|
|
38
|
+
message: string;
|
|
39
|
+
};
|
|
40
|
+
/** Presentational sink — the progress/preview/warning lines runPush emits mid-flow. */
|
|
41
|
+
export interface PushOutput {
|
|
42
|
+
progressStart(msg: string): void;
|
|
43
|
+
progressDone(msg: string): void;
|
|
44
|
+
notice(msg: string): void;
|
|
45
|
+
aiPreview(summary: AiSummary, usage: SummarizeResponse['usage']): void;
|
|
46
|
+
}
|
|
47
|
+
export interface PushDeps {
|
|
48
|
+
git: {
|
|
49
|
+
isGitRepo(): boolean;
|
|
50
|
+
getLastTag(): string | null;
|
|
51
|
+
getHeadVersion(): string | null;
|
|
52
|
+
getCommitsSince(ref: string | null): CommitSummary[];
|
|
53
|
+
defaultTitleFromGit(version: string | null, lastTag: string | null): string;
|
|
54
|
+
formatCommitsAsMarkdown(commits: CommitSummary[]): string;
|
|
55
|
+
};
|
|
56
|
+
api: {
|
|
57
|
+
createEntry(slug: string, input: CreateEntryInput): Promise<Entry>;
|
|
58
|
+
summarize(input: SummarizeInput): Promise<SummarizeResponse>;
|
|
59
|
+
};
|
|
60
|
+
readProjectConfig(): ProjectConfig | null;
|
|
61
|
+
confirm(question: string): Promise<boolean>;
|
|
62
|
+
isTTY: boolean;
|
|
63
|
+
out: PushOutput;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The push orchestration as a pure(ish) function: takes options + injected
|
|
67
|
+
* boundaries, returns a typed result. No `process.exit`, no direct git/network
|
|
68
|
+
* — every branch is reachable from a unit test.
|
|
69
|
+
*/
|
|
70
|
+
export declare function runPush(opts: PushOptions, deps?: PushDeps): Promise<PushResult>;
|
|
71
|
+
export declare const defaultPushDeps: PushDeps;
|
package/dist/push.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { isGitRepo, getLastTag, getHeadVersion, getCommitsSince, defaultTitleFromGit, formatCommitsAsMarkdown, } from './git.js';
|
|
3
|
+
import { createEntry, summarize, } from './api.js';
|
|
4
|
+
import { readProjectConfig } from './project-config.js';
|
|
5
|
+
/**
|
|
6
|
+
* The push orchestration as a pure(ish) function: takes options + injected
|
|
7
|
+
* boundaries, returns a typed result. No `process.exit`, no direct git/network
|
|
8
|
+
* — every branch is reachable from a unit test.
|
|
9
|
+
*/
|
|
10
|
+
export async function runPush(opts, deps = defaultPushDeps) {
|
|
11
|
+
const { git, api, out } = deps;
|
|
12
|
+
// Reconcile each flag with its alias (-g/--git, -a/--ai).
|
|
13
|
+
const fromGit = Boolean(opts.fromGit || opts.git);
|
|
14
|
+
const aiSummarize = Boolean(opts.aiSummarize || opts.ai);
|
|
15
|
+
// Resolve the project (flag > .deploylog.yml). Missing is a refusal, not an exit.
|
|
16
|
+
const projectConfig = deps.readProjectConfig();
|
|
17
|
+
const slug = opts.project ?? projectConfig?.project;
|
|
18
|
+
if (!slug) {
|
|
19
|
+
return {
|
|
20
|
+
kind: 'missing-fields',
|
|
21
|
+
message: 'No project specified. Use --project <slug> or create a .deploylog.yml with:\n project: my-app',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Gather source material (commits + version) if --from-git. This precedes the
|
|
25
|
+
// AI-source check so --ai-summarize can see the commits.
|
|
26
|
+
let commits = [];
|
|
27
|
+
let gitVersion = null;
|
|
28
|
+
let gitTitle = null;
|
|
29
|
+
let gitBody = null;
|
|
30
|
+
if (fromGit) {
|
|
31
|
+
if (!git.isGitRepo()) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'no-commits',
|
|
34
|
+
message: 'Not in a git repository. Remove --from-git or cd to a repo.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const lastTag = git.getLastTag();
|
|
38
|
+
commits = git.getCommitsSince(lastTag);
|
|
39
|
+
gitVersion = git.getHeadVersion();
|
|
40
|
+
gitTitle = git.defaultTitleFromGit(gitVersion, lastTag);
|
|
41
|
+
gitBody = git.formatCommitsAsMarkdown(commits);
|
|
42
|
+
if (commits.length === 0 && !opts.body && !aiSummarize) {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'no-commits',
|
|
45
|
+
message: lastTag
|
|
46
|
+
? `No commits since tag ${lastTag}. Nothing to summarize.`
|
|
47
|
+
: 'No commits found on HEAD.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Build the entry (with optional AI rewrite).
|
|
52
|
+
let title = opts.title ?? gitTitle ?? '';
|
|
53
|
+
let body = opts.body ?? gitBody ?? '';
|
|
54
|
+
let entryType = opts.type ?? projectConfig?.default_type ?? null;
|
|
55
|
+
const version = opts.version ?? gitVersion ?? undefined;
|
|
56
|
+
if (aiSummarize) {
|
|
57
|
+
const hasSource = commits.length > 0 || Boolean(opts.body && opts.body.trim().length > 0);
|
|
58
|
+
if (!hasSource) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'no-ai-source',
|
|
61
|
+
message: '--ai-summarize needs source material. Pass --from-git or provide --body as raw notes.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
out.progressStart('Summarizing with Claude Haiku...');
|
|
65
|
+
const res = await api.summarize({
|
|
66
|
+
project_slug: slug,
|
|
67
|
+
commits: commits.map((c) => c.subject),
|
|
68
|
+
release_notes: opts.body,
|
|
69
|
+
version,
|
|
70
|
+
});
|
|
71
|
+
out.progressDone('done');
|
|
72
|
+
// AI result overrides the body but never an explicit --title.
|
|
73
|
+
title = opts.title ?? res.summary.title;
|
|
74
|
+
body = res.summary.body_markdown;
|
|
75
|
+
entryType = opts.type ?? res.summary.entry_type;
|
|
76
|
+
out.aiPreview(res.summary, res.usage);
|
|
77
|
+
if (!opts.yes && deps.isTTY) {
|
|
78
|
+
const ok = await deps.confirm('Publish this entry?');
|
|
79
|
+
if (!ok) {
|
|
80
|
+
return { kind: 'cancelled', message: 'Cancelled. No entry was created.' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (!opts.yes) {
|
|
84
|
+
// Non-interactive shell (CI, piped stdin): no prompt to show, so make the
|
|
85
|
+
// unreviewed auto-proceed explicit. (BUG-019)
|
|
86
|
+
out.notice('Non-interactive shell: proceeding with the AI-generated entry without confirmation.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!title || !body) {
|
|
90
|
+
return {
|
|
91
|
+
kind: 'missing-fields',
|
|
92
|
+
message: 'Entry requires --title and --body (or --from-git / --ai-summarize to derive them).',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (opts.publish && opts.draft) {
|
|
96
|
+
out.notice('Both --publish and --draft passed; saving as a draft.');
|
|
97
|
+
}
|
|
98
|
+
const entry = await api.createEntry(slug, {
|
|
99
|
+
title,
|
|
100
|
+
body_markdown: body,
|
|
101
|
+
entry_type: entryType,
|
|
102
|
+
version,
|
|
103
|
+
publish: Boolean(opts.publish && !opts.draft),
|
|
104
|
+
});
|
|
105
|
+
return { kind: 'created', entry };
|
|
106
|
+
}
|
|
107
|
+
// ─── real-module defaults ─────────────────────────────────────────────────────
|
|
108
|
+
function printAiPreview(summary, usage) {
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(chalk.bold('AI-generated entry:'));
|
|
111
|
+
console.log(` ${chalk.dim('Title:')} ${summary.title}`);
|
|
112
|
+
console.log(` ${chalk.dim('Type:')} ${summary.entry_type}`);
|
|
113
|
+
console.log(chalk.dim(' Body:'));
|
|
114
|
+
for (const line of summary.body_markdown.split('\n')) {
|
|
115
|
+
console.log(` ${line}`);
|
|
116
|
+
}
|
|
117
|
+
const limitLabel = usage.limit === null ? '∞' : String(usage.limit);
|
|
118
|
+
console.log(chalk.dim(` Usage: ${usage.used}/${limitLabel} this month (${usage.month_key})`));
|
|
119
|
+
console.log();
|
|
120
|
+
}
|
|
121
|
+
async function confirmPrompt(question) {
|
|
122
|
+
const { createInterface } = await import('node:readline');
|
|
123
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
124
|
+
const answer = await new Promise((resolve) => {
|
|
125
|
+
rl.question(`${question} [y/N] `, resolve);
|
|
126
|
+
});
|
|
127
|
+
rl.close();
|
|
128
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
129
|
+
}
|
|
130
|
+
export const defaultPushDeps = {
|
|
131
|
+
git: {
|
|
132
|
+
isGitRepo: () => isGitRepo(),
|
|
133
|
+
getLastTag: () => getLastTag(),
|
|
134
|
+
getHeadVersion: () => getHeadVersion(),
|
|
135
|
+
getCommitsSince: (ref) => getCommitsSince(ref),
|
|
136
|
+
defaultTitleFromGit,
|
|
137
|
+
formatCommitsAsMarkdown,
|
|
138
|
+
},
|
|
139
|
+
api: { createEntry, summarize },
|
|
140
|
+
readProjectConfig: () => readProjectConfig(),
|
|
141
|
+
confirm: confirmPrompt,
|
|
142
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
143
|
+
out: {
|
|
144
|
+
progressStart: (msg) => process.stdout.write(chalk.dim(`${msg} `)),
|
|
145
|
+
progressDone: (msg) => process.stdout.write(chalk.green(`${msg}\n`)),
|
|
146
|
+
notice: (msg) => console.log(chalk.yellow(msg)),
|
|
147
|
+
aiPreview: printAiPreview,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Entry } from './api.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a user-supplied entry reference (slug or id) to an entry id.
|
|
4
|
+
*
|
|
5
|
+
* The id is the canonical handle — slugs are mutable on drafts (a title edit
|
|
6
|
+
* regenerates them), so a uuid-shaped ref is passed straight through and the
|
|
7
|
+
* server's org-scoped lookup is the authority. A slug is matched against the
|
|
8
|
+
* project's recent entries; entries beyond that window need the id.
|
|
9
|
+
*/
|
|
10
|
+
export type ResolveResult = {
|
|
11
|
+
kind: 'resolved';
|
|
12
|
+
id: string;
|
|
13
|
+
entry?: Entry;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'not-found';
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveEntryRef(ref: string, projectSlug: string, listEntries: (slug: string) => Promise<Entry[]>): Promise<ResolveResult>;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
|
+
export async function resolveEntryRef(ref, projectSlug, listEntries) {
|
|
3
|
+
if (UUID_RE.test(ref)) {
|
|
4
|
+
return { kind: 'resolved', id: ref };
|
|
5
|
+
}
|
|
6
|
+
const entries = await listEntries(projectSlug);
|
|
7
|
+
const match = entries.find((e) => e.slug === ref);
|
|
8
|
+
if (match) {
|
|
9
|
+
return { kind: 'resolved', id: match.id, entry: match };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
kind: 'not-found',
|
|
13
|
+
message: `Entry '${ref}' not found in the ${entries.length} most recent entries of '${projectSlug}'.\n` +
|
|
14
|
+
'Older entries must be referenced by id — `deploylog list` shows ids.',
|
|
15
|
+
};
|
|
16
|
+
}
|