@dbx-tools/projen 0.6.223 → 0.6.225
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 +55 -8
- package/index.ts +7 -4
- package/package.json +5 -5
- package/src/barrels.ts +10 -4
- package/src/bun-workflow.ts +36 -17
- package/src/clean.ts +3 -3
- package/src/codegen.ts +16 -8
- package/src/packages.ts +14 -13
- package/src/project-js.ts +320 -148
- package/src/project-py.ts +8 -25
- package/src/project-rs.ts +100 -71
- package/src/release-dispatch.ts +10 -0
- package/src/release-github.ts +37 -0
- package/src/release.ts +57 -38
- package/src/tsconfig.ts +17 -17
- package/tasks/local-publish.ts +3 -3
- package/tasks/publish-npm.ts +75 -4
- package/tasks/publish-python.ts +13 -29
- package/tasks/publish-uniffi-local.ts +12 -9
- package/tasks/publish.ts +2 -2
- package/tasks/release-pr.ts +64 -10
- package/tasks/release-summary.ts +270 -0
- package/tasks/uniffi-python.d.ts +56 -0
- package/tasks/uniffi-python.js +160 -0
- package/tasks/uniffi-release.mjs +25 -49
- package/tasks/uniffi.ts +11 -45
package/tasks/publish-npm.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun
|
|
2
2
|
/** Idempotent npm archive publication for release recovery. */
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readlinkSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join, relative, resolve } from "node:path";
|
|
6
15
|
import { exec } from "@dbx-tools/core";
|
|
7
16
|
import { log } from "@dbx-tools/shared-core";
|
|
8
17
|
import { Command } from "commander";
|
|
@@ -11,6 +20,7 @@ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
|
11
20
|
const logger = log.logger("dbx-tools:publish-npm");
|
|
12
21
|
|
|
13
22
|
export interface NpmReleaseIdentity {
|
|
23
|
+
readonly contentDigest?: string;
|
|
14
24
|
readonly integrity?: string;
|
|
15
25
|
readonly name: string;
|
|
16
26
|
readonly repository?: unknown;
|
|
@@ -47,7 +57,18 @@ export function npmReleaseMatches(
|
|
|
47
57
|
if (localRepository && publishedRepository && localRepository !== publishedRepository) {
|
|
48
58
|
throw new Error(`Published npm repository does not match ${local.name}@${local.version}`);
|
|
49
59
|
}
|
|
50
|
-
if (
|
|
60
|
+
if (
|
|
61
|
+
local.contentDigest &&
|
|
62
|
+
published.contentDigest &&
|
|
63
|
+
local.contentDigest !== published.contentDigest
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(`Published npm content does not match ${local.name}@${local.version}`);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
!(local.contentDigest && published.contentDigest) &&
|
|
69
|
+
local.integrity &&
|
|
70
|
+
published.integrity !== local.integrity
|
|
71
|
+
) {
|
|
51
72
|
throw new Error(`Published npm integrity does not match ${local.name}@${local.version}`);
|
|
52
73
|
}
|
|
53
74
|
return true;
|
|
@@ -66,7 +87,7 @@ export async function publishedNpmRelease(
|
|
|
66
87
|
throw new Error(`npm registry lookup failed for ${name}@${version}: ${response.status}`);
|
|
67
88
|
}
|
|
68
89
|
const metadata = (await response.json()) as {
|
|
69
|
-
dist?: { integrity?: string };
|
|
90
|
+
dist?: { integrity?: string; tarball?: string };
|
|
70
91
|
name?: string;
|
|
71
92
|
repository?: unknown;
|
|
72
93
|
version?: string;
|
|
@@ -74,7 +95,23 @@ export async function publishedNpmRelease(
|
|
|
74
95
|
if (!metadata.name || !metadata.version) {
|
|
75
96
|
throw new Error(`npm registry returned an incomplete identity for ${name}@${version}`);
|
|
76
97
|
}
|
|
98
|
+
let contentDigest: string | undefined;
|
|
99
|
+
if (metadata.dist?.tarball) {
|
|
100
|
+
const archive = await fetch(metadata.dist.tarball);
|
|
101
|
+
if (!archive.ok) {
|
|
102
|
+
throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
|
|
103
|
+
}
|
|
104
|
+
const temp = mkdtempSync(join(tmpdir(), "dbx-tools-published-npm-"));
|
|
105
|
+
const path = join(temp, "package.tgz");
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(path, Buffer.from(await archive.arrayBuffer()));
|
|
108
|
+
contentDigest = npmArchiveContentDigest(path);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(temp, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
77
113
|
return {
|
|
114
|
+
...(contentDigest ? { contentDigest } : {}),
|
|
78
115
|
integrity: metadata.dist?.integrity,
|
|
79
116
|
name: metadata.name,
|
|
80
117
|
repository: metadata.repository,
|
|
@@ -102,6 +139,7 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
|
|
|
102
139
|
throw new Error(`npm archive has no package name or version: ${path}`);
|
|
103
140
|
}
|
|
104
141
|
return {
|
|
142
|
+
contentDigest: npmArchiveContentDigest(path),
|
|
105
143
|
integrity: `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`,
|
|
106
144
|
name: manifest.name,
|
|
107
145
|
repository: manifest.repository,
|
|
@@ -109,6 +147,39 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
|
|
|
109
147
|
};
|
|
110
148
|
}
|
|
111
149
|
|
|
150
|
+
/** Hash paths, executable bits, symlink targets, and bytes while ignoring tar metadata. */
|
|
151
|
+
export function npmArchiveContentDigest(path: string): string {
|
|
152
|
+
const temp = mkdtempSync(join(tmpdir(), "dbx-tools-npm-content-"));
|
|
153
|
+
try {
|
|
154
|
+
exec.spawnSync("tar", ["-xzf", path, "-C", temp], {
|
|
155
|
+
cwd: process.cwd(),
|
|
156
|
+
stdout: "ignore",
|
|
157
|
+
stderr: "capture",
|
|
158
|
+
stdin: "ignore",
|
|
159
|
+
check: true,
|
|
160
|
+
});
|
|
161
|
+
const hash = createHash("sha512");
|
|
162
|
+
const visit = (directory: string): void => {
|
|
163
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
164
|
+
a.name.localeCompare(b.name),
|
|
165
|
+
)) {
|
|
166
|
+
const entryPath = join(directory, entry.name);
|
|
167
|
+
const name = relative(temp, entryPath).split("\\").join("/");
|
|
168
|
+
const stat = lstatSync(entryPath);
|
|
169
|
+
hash.update(`${entry.isDirectory() ? "D" : entry.isSymbolicLink() ? "L" : "F"}\0`);
|
|
170
|
+
hash.update(`${name}\0${stat.mode & 0o111}\0`);
|
|
171
|
+
if (entry.isDirectory()) visit(entryPath);
|
|
172
|
+
else if (entry.isSymbolicLink()) hash.update(readlinkSync(entryPath));
|
|
173
|
+
else hash.update(readFileSync(entryPath));
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
visit(temp);
|
|
177
|
+
return `sha512-${hash.digest("base64")}`;
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(temp, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
112
183
|
export function packNpmPackage(
|
|
113
184
|
directory: string,
|
|
114
185
|
destination: string,
|
package/tasks/publish-python.ts
CHANGED
|
@@ -13,20 +13,18 @@ import { tmpdir } from "node:os";
|
|
|
13
13
|
import { basename, join, resolve } from "node:path";
|
|
14
14
|
import { exec } from "@dbx-tools/core";
|
|
15
15
|
import { Command } from "commander";
|
|
16
|
+
import { parse, stringify } from "smol-toml";
|
|
17
|
+
import { pythonProjectInfo, stampPythonProject } from "./uniffi-python.js";
|
|
16
18
|
|
|
17
19
|
interface PythonProjectFile {
|
|
18
20
|
readonly directory: string;
|
|
19
21
|
readonly mode: number;
|
|
20
22
|
readonly name: string;
|
|
21
23
|
readonly path: string;
|
|
24
|
+
readonly private: boolean;
|
|
22
25
|
readonly source: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
function isPrivatePythonProject(source: string): boolean {
|
|
26
|
-
const section = source.match(/^\[tool\.dbx-tools\]\s*\n([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? "";
|
|
27
|
-
return /^\s*private\s*=\s*true\s*$/m.test(section);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
28
|
export interface StampPythonProjectsOptions {
|
|
31
29
|
readonly rewriteDependencies?: boolean;
|
|
32
30
|
}
|
|
@@ -36,10 +34,6 @@ export interface RestorePythonProjects {
|
|
|
36
34
|
readonly paths: readonly string[];
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
function escapeRegExp(value: string): string {
|
|
40
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
export function stampPythonProjects(
|
|
44
38
|
root: string,
|
|
45
39
|
version: string,
|
|
@@ -52,37 +46,27 @@ export function stampPythonProjects(
|
|
|
52
46
|
.sort();
|
|
53
47
|
const allProjects: PythonProjectFile[] = packageFiles.map((path) => {
|
|
54
48
|
const source = readFileSync(path, "utf8");
|
|
55
|
-
const
|
|
56
|
-
if (!name) throw new Error(`Missing project name in ${path}`);
|
|
49
|
+
const info = pythonProjectInfo(source, { parse, stringify });
|
|
57
50
|
return {
|
|
58
51
|
directory: basename(resolve(path, "..")),
|
|
59
52
|
mode: statSync(path).mode,
|
|
60
|
-
name,
|
|
53
|
+
name: info.name,
|
|
61
54
|
path,
|
|
55
|
+
private: info.private,
|
|
62
56
|
source,
|
|
63
57
|
};
|
|
64
58
|
});
|
|
65
|
-
const projects = allProjects.filter((project) => !
|
|
59
|
+
const projects = allProjects.filter((project) => !project.private);
|
|
66
60
|
if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
|
|
67
61
|
|
|
68
62
|
try {
|
|
69
63
|
for (const project of projects) {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const sibling of allProjects) {
|
|
77
|
-
stamped = stamped.replace(
|
|
78
|
-
new RegExp(
|
|
79
|
-
`${escapeRegExp(sibling.name)} @ git\\+[^" ]+#subdirectory=[^" ]+/${escapeRegExp(sibling.directory)}`,
|
|
80
|
-
"g",
|
|
81
|
-
),
|
|
82
|
-
`${sibling.name}==${version}`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
64
|
+
const stamped = stampPythonProject(project.source, {
|
|
65
|
+
packages: allProjects,
|
|
66
|
+
rewriteDependencies: options.rewriteDependencies,
|
|
67
|
+
toml: { parse, stringify },
|
|
68
|
+
version,
|
|
69
|
+
});
|
|
86
70
|
chmodSync(project.path, project.mode | 0o200);
|
|
87
71
|
writeFileSync(project.path, stamped);
|
|
88
72
|
chmodSync(project.path, project.mode);
|
|
@@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { log } from "@dbx-tools/shared-core";
|
|
9
|
+
import { parse, stringify } from "smol-toml";
|
|
9
10
|
import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
|
|
10
11
|
import type { RustBindingMapping, RustWorkspaceMapping } from "../src/project-rs.ts";
|
|
11
12
|
|
|
@@ -110,19 +111,21 @@ function publishCargo(config: RustWorkspaceMapping, registry: string, version: s
|
|
|
110
111
|
);
|
|
111
112
|
for (const manifest of manifests) {
|
|
112
113
|
const mode = statSync(manifest).mode;
|
|
113
|
-
|
|
114
|
+
const document = parse(readFileSync(manifest, "utf8")) as Record<string, unknown>;
|
|
114
115
|
for (const crateName of crateNames) {
|
|
115
116
|
if (!crateName) continue;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
for (const sectionName of ["dependencies", "dev-dependencies", "build-dependencies"]) {
|
|
118
|
+
const section = document[sectionName];
|
|
119
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) continue;
|
|
120
|
+
const dependency = (section as Record<string, unknown>)[crateName];
|
|
121
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
(dependency as Record<string, unknown>).registry = registry;
|
|
125
|
+
}
|
|
123
126
|
}
|
|
124
127
|
chmodSync(manifest, mode | 0o200);
|
|
125
|
-
writeFileSync(manifest,
|
|
128
|
+
writeFileSync(manifest, `${stringify(document).trimEnd()}\n`);
|
|
126
129
|
chmodSync(manifest, mode);
|
|
127
130
|
}
|
|
128
131
|
for (const crateName of crateNames) {
|
package/tasks/publish.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
writeFileSync,
|
|
63
63
|
} from "node:fs";
|
|
64
64
|
import { tmpdir } from "node:os";
|
|
65
|
-
import { dirname, join, resolve } from "node:path";
|
|
65
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
66
66
|
import { exec } from "@dbx-tools/core";
|
|
67
67
|
import { log } from "@dbx-tools/shared-core";
|
|
68
68
|
import ts from "typescript";
|
|
@@ -253,7 +253,7 @@ function applyPublishConfig(pkgPath: string): void {
|
|
|
253
253
|
function enrichedPath(root: string): string {
|
|
254
254
|
const binDir = join(root, "node_modules", ".bin");
|
|
255
255
|
const current = process.env.PATH ?? "";
|
|
256
|
-
return current.split(
|
|
256
|
+
return current.split(delimiter).includes(binDir) ? current : `${binDir}${delimiter}${current}`;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
const [version, ...rest] = process.argv.slice(2);
|
package/tasks/release-pr.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { exec, project } from "@dbx-tools/core";
|
|
14
14
|
import { log } from "@dbx-tools/shared-core";
|
|
15
15
|
import { Command } from "commander";
|
|
16
|
+
import { publishLocalRelease } from "./local-publish.ts";
|
|
17
|
+
import { generateReleaseSummary } from "./release-summary.ts";
|
|
16
18
|
import {
|
|
17
19
|
releaseArchitectureOption,
|
|
18
20
|
releaseLevelOption,
|
|
@@ -21,7 +23,16 @@ import {
|
|
|
21
23
|
type ReleaseOs,
|
|
22
24
|
type VersionLevel,
|
|
23
25
|
} from "../src/_release-platform.ts";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
githubRepositoryIdentity,
|
|
28
|
+
githubRepositorySpecifier,
|
|
29
|
+
githubTokenArguments,
|
|
30
|
+
githubTokenEnvironmentName,
|
|
31
|
+
} from "../src/release-github.ts";
|
|
32
|
+
import {
|
|
33
|
+
RELEASE_SUMMARY_PROVIDER_NAMES,
|
|
34
|
+
type ReleaseSummaryProviderName,
|
|
35
|
+
} from "../src/release-dispatch.ts";
|
|
25
36
|
import { readWorkspaceVersion, resolveNextVersion } from "../src/workspace-version.ts";
|
|
26
37
|
|
|
27
38
|
const logger = log.logger("projen:release");
|
|
@@ -100,13 +111,17 @@ function runIgnoringStdout(root: string, command: string, args: string[]): void
|
|
|
100
111
|
});
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
function githubAccount(root: string): {
|
|
114
|
+
function githubAccount(root: string): {
|
|
115
|
+
hostname: string;
|
|
116
|
+
owner: string;
|
|
117
|
+
repository: string;
|
|
118
|
+
token: string;
|
|
119
|
+
} {
|
|
104
120
|
const repository = project.repositoryUrl(root);
|
|
105
121
|
if (!repository) throw new Error("Release preparation requires a GitHub repository");
|
|
106
|
-
const
|
|
107
|
-
if (!owner || !name) throw new Error(`Cannot determine GitHub identity from ${repository}`);
|
|
122
|
+
const identity = githubRepositoryIdentity(repository);
|
|
108
123
|
const token = exec
|
|
109
|
-
.spawnSync("gh",
|
|
124
|
+
.spawnSync("gh", githubTokenArguments(identity.hostname), {
|
|
110
125
|
cwd: root,
|
|
111
126
|
stdout: "capture",
|
|
112
127
|
stderr: "ignore",
|
|
@@ -114,8 +129,26 @@ function githubAccount(root: string): { owner: string; repository: string; token
|
|
|
114
129
|
check: true,
|
|
115
130
|
})
|
|
116
131
|
.stdout?.trim();
|
|
117
|
-
if (!token)
|
|
118
|
-
|
|
132
|
+
if (!token) {
|
|
133
|
+
throw new Error(`No GitHub CLI authentication found for ${identity.hostname}`);
|
|
134
|
+
}
|
|
135
|
+
return { ...identity, token };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function releaseSummaryProviders(
|
|
139
|
+
value: string | undefined,
|
|
140
|
+
): ReleaseSummaryProviderName[] | undefined {
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
const providers = value
|
|
143
|
+
.split(",")
|
|
144
|
+
.map((provider) => provider.trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
for (const provider of providers) {
|
|
147
|
+
if (!RELEASE_SUMMARY_PROVIDER_NAMES.includes(provider as ReleaseSummaryProviderName)) {
|
|
148
|
+
throw new Error(`Unknown release summary provider: ${provider}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return providers as ReleaseSummaryProviderName[];
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
const program = new Command();
|
|
@@ -129,7 +162,12 @@ program
|
|
|
129
162
|
.addOption(releaseArchitectureOption())
|
|
130
163
|
.option("--local-registry <value>", "local npm registry: auto, false, or an explicit URL", "auto")
|
|
131
164
|
.option("--local-pypi <value>", "local PyPI index: auto, false, or an explicit URL", "auto")
|
|
132
|
-
.option("--python-root <path>", "Python workspace package root"
|
|
165
|
+
.option("--python-root <path>", "Python workspace package root")
|
|
166
|
+
.option("--no-release-summary", "skip optional AI release summary generation")
|
|
167
|
+
.option(
|
|
168
|
+
"--release-summary-providers <providers>",
|
|
169
|
+
"comma-separated provider order: cursor,codex,claude",
|
|
170
|
+
)
|
|
133
171
|
.option("--no-local-cargo", "skip local Cargo publication")
|
|
134
172
|
.option("--approve", "merge the release branch directly into the release base")
|
|
135
173
|
.action(
|
|
@@ -142,7 +180,9 @@ program
|
|
|
142
180
|
arch: ReleaseArch[];
|
|
143
181
|
localRegistry: string;
|
|
144
182
|
localPypi: string;
|
|
145
|
-
pythonRoot
|
|
183
|
+
pythonRoot?: string;
|
|
184
|
+
releaseSummary: boolean;
|
|
185
|
+
releaseSummaryProviders?: string;
|
|
146
186
|
localCargo: boolean;
|
|
147
187
|
approve: boolean;
|
|
148
188
|
}) => {
|
|
@@ -247,6 +287,14 @@ program
|
|
|
247
287
|
localCargo: opts.localCargo,
|
|
248
288
|
});
|
|
249
289
|
run(releaseRoot, process.execPath, [versionCheckScript]);
|
|
290
|
+
const releaseSummary = opts.releaseSummary
|
|
291
|
+
? await generateReleaseSummary({
|
|
292
|
+
root: releaseRoot,
|
|
293
|
+
version: next.version,
|
|
294
|
+
fromRef: `${opts.prefix}${next.base}`,
|
|
295
|
+
providers: releaseSummaryProviders(opts.releaseSummaryProviders),
|
|
296
|
+
})
|
|
297
|
+
: undefined;
|
|
250
298
|
|
|
251
299
|
git(releaseRoot, ["add", "-A"]);
|
|
252
300
|
const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
|
|
@@ -267,8 +315,14 @@ program
|
|
|
267
315
|
`Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
|
|
268
316
|
"",
|
|
269
317
|
"Merging this PR updates VERSION on main and starts the public release workflow.",
|
|
318
|
+
...(releaseSummary ? ["", releaseSummary] : []),
|
|
270
319
|
].join("\n");
|
|
271
|
-
const githubEnvironment = {
|
|
320
|
+
const githubEnvironment = {
|
|
321
|
+
...process.env,
|
|
322
|
+
GH_HOST: account.hostname,
|
|
323
|
+
GH_REPO: githubRepositorySpecifier(account),
|
|
324
|
+
[githubTokenEnvironmentName(account.hostname)]: account.token,
|
|
325
|
+
};
|
|
272
326
|
const ensurePullRequest = (): void => {
|
|
273
327
|
if (commandSucceeds(root, "gh", ["pr", "view", releaseBranch], githubEnvironment)) return;
|
|
274
328
|
run(
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun
|
|
2
|
+
/**
|
|
3
|
+
* Optional AI-generated release summary with CLI fallback.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { exec } from "@dbx-tools/core";
|
|
10
|
+
import { json, log, object, string } from "@dbx-tools/shared-core";
|
|
11
|
+
import {
|
|
12
|
+
RELEASE_SUMMARY_PROVIDER_NAMES,
|
|
13
|
+
releaseSummaryFile,
|
|
14
|
+
type ReleaseSummaryProviderName,
|
|
15
|
+
} from "../src/release-dispatch.ts";
|
|
16
|
+
|
|
17
|
+
const logger = log.logger("projen:release-summary");
|
|
18
|
+
|
|
19
|
+
export interface ReleaseSummaryProvider {
|
|
20
|
+
readonly name: ReleaseSummaryProviderName;
|
|
21
|
+
readonly command: string;
|
|
22
|
+
readonly probeArgs: readonly string[];
|
|
23
|
+
args(root: string, prompt: string): string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const RELEASE_SUMMARY_PROVIDERS: readonly ReleaseSummaryProvider[] = [
|
|
27
|
+
{
|
|
28
|
+
name: "cursor",
|
|
29
|
+
command: "cursor",
|
|
30
|
+
probeArgs: ["agent", "--version"],
|
|
31
|
+
args: (root, prompt) => [
|
|
32
|
+
"agent",
|
|
33
|
+
"--print",
|
|
34
|
+
"--mode",
|
|
35
|
+
"ask",
|
|
36
|
+
"--output-format",
|
|
37
|
+
"stream-json",
|
|
38
|
+
"--trust",
|
|
39
|
+
"--workspace",
|
|
40
|
+
root,
|
|
41
|
+
prompt,
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "codex",
|
|
46
|
+
command: "codex",
|
|
47
|
+
probeArgs: ["--version"],
|
|
48
|
+
args: (root, prompt) => [
|
|
49
|
+
"exec",
|
|
50
|
+
"--sandbox",
|
|
51
|
+
"read-only",
|
|
52
|
+
"--skip-git-repo-check",
|
|
53
|
+
"--json",
|
|
54
|
+
"--color",
|
|
55
|
+
"never",
|
|
56
|
+
"-C",
|
|
57
|
+
root,
|
|
58
|
+
prompt,
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "claude",
|
|
63
|
+
command: "claude",
|
|
64
|
+
probeArgs: ["--version"],
|
|
65
|
+
args: (_root, prompt) => [
|
|
66
|
+
"--print",
|
|
67
|
+
"--no-session-persistence",
|
|
68
|
+
"--output-format",
|
|
69
|
+
"stream-json",
|
|
70
|
+
"--verbose",
|
|
71
|
+
"--tools",
|
|
72
|
+
"",
|
|
73
|
+
prompt,
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export type ReleaseSummaryRunner = (
|
|
79
|
+
provider: ReleaseSummaryProvider,
|
|
80
|
+
root: string,
|
|
81
|
+
prompt: string,
|
|
82
|
+
) => string | undefined | Promise<string | undefined>;
|
|
83
|
+
|
|
84
|
+
function capture(root: string, command: string, args: string[]): string {
|
|
85
|
+
const result = exec.spawnSync(command, args, {
|
|
86
|
+
cwd: root,
|
|
87
|
+
stdout: "capture",
|
|
88
|
+
stderr: "ignore",
|
|
89
|
+
stdin: "ignore",
|
|
90
|
+
check: false,
|
|
91
|
+
});
|
|
92
|
+
return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function contentText(value: unknown): string | undefined {
|
|
96
|
+
if (typeof value === "string") return string.trimToNull(value) ?? undefined;
|
|
97
|
+
if (!Array.isArray(value)) return undefined;
|
|
98
|
+
const text = value
|
|
99
|
+
.flatMap((item) => (object.isRecord(item) && typeof item.text === "string" ? [item.text] : []))
|
|
100
|
+
.join("");
|
|
101
|
+
return string.trimToNull(text) ?? undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function eventText(event: Record<string, unknown>): string | undefined {
|
|
105
|
+
if (typeof event.result === "string") return string.trimToNull(event.result) ?? undefined;
|
|
106
|
+
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
107
|
+
if (item?.type === "agent_message" && typeof item.text === "string") {
|
|
108
|
+
return string.trimToNull(item.text) ?? undefined;
|
|
109
|
+
}
|
|
110
|
+
const message = object.isRecord(event.message) ? event.message : undefined;
|
|
111
|
+
return contentText(message?.content);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function eventType(event: Record<string, unknown>): string {
|
|
115
|
+
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
116
|
+
return [event.type, item?.type].filter((value) => typeof value === "string").join(":") || "event";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function eventDetail(event: Record<string, unknown>): string | undefined {
|
|
120
|
+
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
121
|
+
for (const value of [item?.message, event.message, event.error]) {
|
|
122
|
+
if (typeof value === "string") return string.trimToNull(value) ?? undefined;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runProvider(
|
|
128
|
+
provider: ReleaseSummaryProvider,
|
|
129
|
+
root: string,
|
|
130
|
+
prompt: string,
|
|
131
|
+
): Promise<string | undefined> {
|
|
132
|
+
if (!capture(root, provider.command, [...provider.probeArgs])) return undefined;
|
|
133
|
+
let summary: string | undefined;
|
|
134
|
+
const textOutput: string[] = [];
|
|
135
|
+
const process = exec.spawn(provider.command, provider.args(root, prompt), {
|
|
136
|
+
cwd: root,
|
|
137
|
+
stdout: [
|
|
138
|
+
"capture",
|
|
139
|
+
(line) => {
|
|
140
|
+
const event = json.parseRecord(line);
|
|
141
|
+
if (!event) {
|
|
142
|
+
const text = string.trimToNull(line);
|
|
143
|
+
if (text) {
|
|
144
|
+
textOutput.push(text);
|
|
145
|
+
logger.info("provider-event", { provider: provider.name, type: "text", text });
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const text = eventText(event);
|
|
150
|
+
const detail = eventDetail(event);
|
|
151
|
+
if (text) summary = text;
|
|
152
|
+
logger.info("provider-event", {
|
|
153
|
+
provider: provider.name,
|
|
154
|
+
type: eventType(event),
|
|
155
|
+
...(text ? { text } : {}),
|
|
156
|
+
...(detail ? { detail } : {}),
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
stderr: (line) => {
|
|
161
|
+
const message = string.trimToNull(line);
|
|
162
|
+
if (message) logger.warn("provider-stderr", { provider: provider.name, message });
|
|
163
|
+
},
|
|
164
|
+
stdin: "ignore",
|
|
165
|
+
check: false,
|
|
166
|
+
});
|
|
167
|
+
const result = await process;
|
|
168
|
+
if (result.exitCode !== 0) {
|
|
169
|
+
logger.warn("provider-failed", { provider: provider.name, exitCode: result.exitCode });
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return summary ?? (textOutput.length ? textOutput.join("\n") : undefined);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Return the first non-empty provider response in Cursor, Codex, Claude order. */
|
|
176
|
+
export async function selectReleaseSummary(
|
|
177
|
+
root: string,
|
|
178
|
+
prompt: string,
|
|
179
|
+
runner: ReleaseSummaryRunner = runProvider,
|
|
180
|
+
providers: readonly ReleaseSummaryProviderName[] = RELEASE_SUMMARY_PROVIDER_NAMES,
|
|
181
|
+
): Promise<{ provider: ReleaseSummaryProvider["name"]; summary: string } | undefined> {
|
|
182
|
+
for (const name of providers) {
|
|
183
|
+
const provider = RELEASE_SUMMARY_PROVIDERS.find((candidate) => candidate.name === name);
|
|
184
|
+
if (!provider) continue;
|
|
185
|
+
const summary = (await runner(provider, root, prompt))?.trim();
|
|
186
|
+
if (summary) return { provider: provider.name, summary };
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function summaryPrompt(
|
|
192
|
+
version: string,
|
|
193
|
+
fromRef: string | undefined,
|
|
194
|
+
commits: string,
|
|
195
|
+
changedFiles: string,
|
|
196
|
+
diffStat: string,
|
|
197
|
+
): string {
|
|
198
|
+
return [
|
|
199
|
+
`Write a concise user-facing Markdown release summary for dbx-tools ${version}.`,
|
|
200
|
+
"Use only the supplied Git context. Do not run commands, edit files, speculate,",
|
|
201
|
+
"mention commit hashes, use emojis, or use em/en dashes.",
|
|
202
|
+
"Return no title and no fenced block. Start with one short paragraph, then",
|
|
203
|
+
"group concrete changes under brief Markdown headings with bullets.",
|
|
204
|
+
"",
|
|
205
|
+
`Comparison base: ${fromRef ?? "repository root"}`,
|
|
206
|
+
"",
|
|
207
|
+
"Commits:",
|
|
208
|
+
commits || "(none)",
|
|
209
|
+
"",
|
|
210
|
+
"Changed files:",
|
|
211
|
+
changedFiles || "(none)",
|
|
212
|
+
"",
|
|
213
|
+
"Diff stat:",
|
|
214
|
+
diffStat || "(none)",
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Generate one immutable versioned summary, or remove a stale retry artifact. */
|
|
219
|
+
export async function generateReleaseSummary(options: {
|
|
220
|
+
readonly root: string;
|
|
221
|
+
readonly version: string;
|
|
222
|
+
readonly fromRef?: string;
|
|
223
|
+
readonly toRef?: string;
|
|
224
|
+
readonly providers?: readonly ReleaseSummaryProviderName[];
|
|
225
|
+
readonly runner?: ReleaseSummaryRunner;
|
|
226
|
+
}): Promise<string | undefined> {
|
|
227
|
+
const relativeOutput = releaseSummaryFile(options.version);
|
|
228
|
+
const output = join(options.root, relativeOutput);
|
|
229
|
+
if (existsSync(output)) rmSync(output);
|
|
230
|
+
const target =
|
|
231
|
+
options.toRef && capture(options.root, "git", ["rev-parse", "--verify", options.toRef])
|
|
232
|
+
? options.toRef
|
|
233
|
+
: "HEAD";
|
|
234
|
+
const range =
|
|
235
|
+
options.fromRef && capture(options.root, "git", ["rev-parse", "--verify", options.fromRef])
|
|
236
|
+
? `${options.fromRef}..${target}`
|
|
237
|
+
: target;
|
|
238
|
+
const commits = capture(options.root, "git", ["log", "--no-merges", "--format=%s%n%b", range]);
|
|
239
|
+
const changedFiles = capture(options.root, "git", [
|
|
240
|
+
"diff",
|
|
241
|
+
"--name-status",
|
|
242
|
+
range,
|
|
243
|
+
"--",
|
|
244
|
+
".",
|
|
245
|
+
`:(exclude)${relativeOutput}`,
|
|
246
|
+
]);
|
|
247
|
+
const diffStat = capture(options.root, "git", [
|
|
248
|
+
"diff",
|
|
249
|
+
"--stat",
|
|
250
|
+
range,
|
|
251
|
+
"--",
|
|
252
|
+
".",
|
|
253
|
+
`:(exclude)${relativeOutput}`,
|
|
254
|
+
]);
|
|
255
|
+
const result = await selectReleaseSummary(
|
|
256
|
+
options.root,
|
|
257
|
+
summaryPrompt(options.version, options.fromRef, commits, changedFiles, diffStat),
|
|
258
|
+
options.runner,
|
|
259
|
+
options.providers,
|
|
260
|
+
);
|
|
261
|
+
if (!result) {
|
|
262
|
+
logger.info("no supported AI CLI available; skipping release summary");
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const content = `# Release ${options.version}\n\n${result.summary.trim()}\n`;
|
|
266
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
267
|
+
writeFileSync(output, content);
|
|
268
|
+
logger.info("generated", { provider: result.provider, path: output });
|
|
269
|
+
return content;
|
|
270
|
+
}
|