@dbx-tools/projen 0.9.19 → 0.9.28
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 +83 -229
- package/index.ts +11 -18
- package/package.json +9 -5
- package/src/_task-command.ts +75 -0
- package/src/barrels.ts +1 -74
- package/src/module-exports.ts +175 -1
- package/src/openapi.ts +5 -72
- package/src/packages.ts +45 -0
- package/src/project-js.ts +5 -48
- package/src/project-predicate.ts +1 -2
- package/src/project-py.ts +87 -199
- package/src/project.ts +2 -2
- package/src/python-node-bindings.ts +138 -0
- package/src/python-node-bundle.ts +92 -0
- package/src/release-catalog.ts +40 -208
- package/src/{release-dispatch.ts → release-context.ts} +0 -11
- package/src/release.ts +74 -778
- package/src/scaffold.ts +2 -6
- package/src/workspace-version.ts +9 -121
- package/tasks/bump.ts +17 -34
- package/tasks/databricks-cli-assets.ts +129 -0
- package/tasks/local-publish.ts +61 -138
- package/tasks/publish-npm.ts +71 -30
- package/tasks/publish-python.ts +61 -148
- package/tasks/publish.ts +32 -182
- package/tasks/python-node-bindings-watch.ts +47 -0
- package/tasks/python-node-bindings.ts +1017 -0
- package/tasks/python-registry.ts +2 -9
- package/tasks/python-release.ts +74 -0
- package/tasks/release-version.ts +23 -2
- package/tasks/release.ts +99 -0
- package/tasks/sync.ts +13 -18
- package/tasks/version-check.ts +13 -72
- package/src/_rust-project.ts +0 -429
- package/src/_rust-release-workflow.ts +0 -698
- package/src/project-rs.ts +0 -1127
- package/src/release-plan.ts +0 -269
- package/src/release-please.ts +0 -242
- package/src/uniffi.ts +0 -65
- package/tasks/publish-uniffi-local.ts +0 -314
- package/tasks/release-assets.ts +0 -476
- package/tasks/release-bootstrap.ts +0 -59
- package/tasks/release-candidate.ts +0 -383
- package/tasks/release-manifest.ts +0 -273
- package/tasks/release-plan.ts +0 -89
- package/tasks/release-please.ts +0 -162
- package/tasks/release-pr.ts +0 -563
- package/tasks/release-request.ts +0 -166
- package/tasks/release-summary-units.ts +0 -75
- package/tasks/release-summary.ts +0 -381
- package/tasks/rust.ts +0 -178
- package/tasks/stamp-python.ts +0 -13
- package/tasks/uniffi-python.d.ts +0 -61
- package/tasks/uniffi-python.js +0 -191
- package/tasks/uniffi-release.mjs +0 -573
- package/tasks/uniffi.ts +0 -271
package/tasks/release-request.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env -S bun
|
|
2
|
-
/** Commit and push a source branch for the generated release-request workflow. */
|
|
3
|
-
|
|
4
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname, resolve } from "node:path";
|
|
6
|
-
import * as projectUtils from "@dbx-tools/core/project-utils";
|
|
7
|
-
import { log, stringUtils } from "@dbx-tools/shared-core";
|
|
8
|
-
import { Command } from "commander";
|
|
9
|
-
import { selectReleaseSummary } from "./release-summary.ts";
|
|
10
|
-
import { captureTaskCommand, runTaskCommand } from "../src/_task-command.ts";
|
|
11
|
-
|
|
12
|
-
const logger = log.logger("projen:release-request");
|
|
13
|
-
|
|
14
|
-
export interface PrepareReleaseRequestOptions {
|
|
15
|
-
readonly root: string;
|
|
16
|
-
readonly baseBranch: string;
|
|
17
|
-
readonly message?: string;
|
|
18
|
-
readonly notes?: string;
|
|
19
|
-
readonly notesFile?: string;
|
|
20
|
-
readonly push?: boolean;
|
|
21
|
-
readonly synthesize?: boolean;
|
|
22
|
-
readonly generateNotes?: (root: string, range: string) => Promise<string>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Commit real changes, attach notes to one request commit, and push the branch. */
|
|
26
|
-
export async function prepareReleaseRequest(
|
|
27
|
-
options: PrepareReleaseRequestOptions,
|
|
28
|
-
): Promise<boolean> {
|
|
29
|
-
const root = resolve(options.root);
|
|
30
|
-
const branch = capture(root, ["branch", "--show-current"]);
|
|
31
|
-
if (!branch) throw new Error("Release requests require a named source branch");
|
|
32
|
-
if (options.push !== false) {
|
|
33
|
-
run(root, ["fetch", "origin", options.baseBranch]);
|
|
34
|
-
}
|
|
35
|
-
const baseRef = options.push === false ? options.baseBranch : `origin/${options.baseBranch}`;
|
|
36
|
-
if (options.synthesize !== false) {
|
|
37
|
-
runTaskCommand(root, process.execPath, ["x", "projen"]);
|
|
38
|
-
}
|
|
39
|
-
const dirty = capture(root, ["status", "--porcelain"]);
|
|
40
|
-
if (dirty) {
|
|
41
|
-
const message = options.message ?? "fix: prepare release";
|
|
42
|
-
if (!isReleasableMessage(message)) {
|
|
43
|
-
throw new Error("Release commit message must use fix, feat, or a breaking-change marker");
|
|
44
|
-
}
|
|
45
|
-
run(root, ["add", "-A"]);
|
|
46
|
-
run(root, ["commit", "-m", message]);
|
|
47
|
-
}
|
|
48
|
-
const ahead = Number(capture(root, ["rev-list", "--count", `${baseRef}..HEAD`]));
|
|
49
|
-
if (!Number.isInteger(ahead) || ahead < 1) {
|
|
50
|
-
logger.info("no changes from release base; skipping release request", {
|
|
51
|
-
branch,
|
|
52
|
-
base: options.baseBranch,
|
|
53
|
-
});
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
const messages = capture(root, ["log", "--format=%B%x00", `${baseRef}..HEAD`]);
|
|
57
|
-
if (!messages.split("\0").some(isReleasableMessage)) {
|
|
58
|
-
logger.info("branch has no releasable conventional commits; skipping release request", {
|
|
59
|
-
branch,
|
|
60
|
-
});
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
const latest = capture(root, ["log", "-1", "--format=%B"]);
|
|
64
|
-
if (/^Release-Request:\s*true$/m.test(latest)) {
|
|
65
|
-
logger.info("release request already exists at branch head; skipping", { branch });
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
if (branch === options.baseBranch) {
|
|
69
|
-
throw new Error(`Release requests must run from a branch other than ${options.baseBranch}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const customNotes = stringUtils.trimToNull(
|
|
73
|
-
options.notesFile ? readFileSync(resolve(root, options.notesFile), "utf8") : options.notes,
|
|
74
|
-
);
|
|
75
|
-
const notes =
|
|
76
|
-
customNotes ??
|
|
77
|
-
(await (options.generateNotes ?? generateRequestNotes)(root, `${baseRef}..HEAD`));
|
|
78
|
-
const notesPath = `.release-notes/requests/${branch.replace(/[^a-zA-Z0-9-]+/g, "-")}.md`;
|
|
79
|
-
const absoluteNotesPath = resolve(root, notesPath);
|
|
80
|
-
mkdirSync(dirname(absoluteNotesPath), { recursive: true });
|
|
81
|
-
writeFileSync(absoluteNotesPath, `# Release request: ${branch}\n\n${notes.trim()}\n`);
|
|
82
|
-
run(root, ["add", notesPath]);
|
|
83
|
-
run(root, [
|
|
84
|
-
"commit",
|
|
85
|
-
"-m",
|
|
86
|
-
"chore: request release",
|
|
87
|
-
"-m",
|
|
88
|
-
[
|
|
89
|
-
"Release-Request: true",
|
|
90
|
-
`Release-Source-Branch: ${branch}`,
|
|
91
|
-
`Release-Notes-Path: ${notesPath}`,
|
|
92
|
-
].join("\n"),
|
|
93
|
-
]);
|
|
94
|
-
if (options.push !== false) run(root, ["push", "--set-upstream", "origin", branch]);
|
|
95
|
-
logger.success("release request pushed", { branch });
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Whether one conventional commit can produce a Release Please increment. */
|
|
100
|
-
export function isReleasableMessage(message: string): boolean {
|
|
101
|
-
return (
|
|
102
|
-
/^(?:fix|feat)(?:\([^)\r\n]+\))?!?:/m.test(message) || /^BREAKING[ -]CHANGE:/m.test(message)
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function generateRequestNotes(root: string, range: string): Promise<string> {
|
|
107
|
-
const commits = capture(root, ["log", "--no-merges", "--format=%s%n%b", range]);
|
|
108
|
-
const files = capture(root, ["diff", "--name-status", range, "--", "."]);
|
|
109
|
-
const result = await selectReleaseSummary(
|
|
110
|
-
root,
|
|
111
|
-
[
|
|
112
|
-
"Write concise release-request notes for the reviewed changes below.",
|
|
113
|
-
"Focus on user-visible behavior and important compatibility details.",
|
|
114
|
-
"",
|
|
115
|
-
"Commits:",
|
|
116
|
-
commits,
|
|
117
|
-
"",
|
|
118
|
-
"Files:",
|
|
119
|
-
files,
|
|
120
|
-
].join("\n"),
|
|
121
|
-
);
|
|
122
|
-
if (result?.summary.trim()) return result.summary.trim();
|
|
123
|
-
const subjects = capture(root, ["log", "--no-merges", "--format=- %s", range]);
|
|
124
|
-
return subjects || "Release source changes are ready for review.";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function capture(root: string, args: string[]): string {
|
|
128
|
-
return captureTaskCommand(root, "git", args, { check: true, stderr: "inherit" });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function run(root: string, args: string[]): void {
|
|
132
|
-
runTaskCommand(root, "git", args);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Restore option text after Projen forwards task arguments without shell quoting. */
|
|
136
|
-
function optionText(values?: readonly string[]): string | undefined {
|
|
137
|
-
return values?.join(" ");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (import.meta.main) {
|
|
141
|
-
await new Command()
|
|
142
|
-
.option("--base <branch>", "release target branch", "main")
|
|
143
|
-
.option("--message <message...>", "commit message for uncommitted source changes")
|
|
144
|
-
.option("--notes <notes...>", "custom release notes")
|
|
145
|
-
.option("--notes-file <path>", "read custom release notes from a file")
|
|
146
|
-
.action(
|
|
147
|
-
async (options: {
|
|
148
|
-
base: string;
|
|
149
|
-
message?: string[];
|
|
150
|
-
notes?: string[];
|
|
151
|
-
notesFile?: string;
|
|
152
|
-
}) => {
|
|
153
|
-
if (options.notes && options.notesFile) {
|
|
154
|
-
throw new Error("Use --notes or --notes-file, not both");
|
|
155
|
-
}
|
|
156
|
-
await prepareReleaseRequest({
|
|
157
|
-
root: projectUtils.root() ?? process.cwd(),
|
|
158
|
-
baseBranch: options.base,
|
|
159
|
-
message: optionText(options.message),
|
|
160
|
-
notes: optionText(options.notes),
|
|
161
|
-
notesFile: options.notesFile,
|
|
162
|
-
});
|
|
163
|
-
},
|
|
164
|
-
)
|
|
165
|
-
.parseAsync();
|
|
166
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env -S bun
|
|
2
|
-
/** Generate component-qualified release notes from an affected release plan. */
|
|
3
|
-
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
6
|
-
import { log } from "@dbx-tools/shared-core";
|
|
7
|
-
import { Command } from "commander";
|
|
8
|
-
import { generateReleaseSummary } from "./release-summary.ts";
|
|
9
|
-
import { captureTaskCommand } from "../src/_task-command.ts";
|
|
10
|
-
import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
|
|
11
|
-
import type { ReleasePlan } from "../src/release-plan.ts";
|
|
12
|
-
|
|
13
|
-
const logger = log.logger("projen:release-summary-units");
|
|
14
|
-
|
|
15
|
-
if (import.meta.main) {
|
|
16
|
-
await new Command()
|
|
17
|
-
.option("--root <path>", "repository root", ".")
|
|
18
|
-
.option("--plan <path>", "affected release plan", "dist/release-plan.json")
|
|
19
|
-
.option("--from-ref <ref>", "summary base ref", "origin/main")
|
|
20
|
-
.option("--to-ref <ref>", "summary target ref", "HEAD")
|
|
21
|
-
.action(async (options: { root: string; plan: string; fromRef: string; toRef: string }) => {
|
|
22
|
-
const root = resolve(options.root);
|
|
23
|
-
const plan = JSON.parse(readFileSync(resolve(root, options.plan), "utf8")) as ReleasePlan;
|
|
24
|
-
const graph = JSON.parse(
|
|
25
|
-
readFileSync(resolve(root, ".projen/release-units.json"), "utf8"),
|
|
26
|
-
) as ReleaseUnitGraph;
|
|
27
|
-
const request = requestNotes(root);
|
|
28
|
-
for (const planned of plan.units) {
|
|
29
|
-
const unit = graph.units.find((candidate) => candidate.id === planned.id);
|
|
30
|
-
if (!unit) throw new Error(`Release plan references unknown unit ${planned.id}`);
|
|
31
|
-
const paths = graph.projects
|
|
32
|
-
.filter((project) => project.unit === unit.id)
|
|
33
|
-
.map((project) => project.path);
|
|
34
|
-
const componentTag = `${planned.component}-v${planned.oldVersion}`;
|
|
35
|
-
const fromRef = gitCapture(root, ["rev-parse", "--verify", componentTag])
|
|
36
|
-
? componentTag
|
|
37
|
-
: options.fromRef;
|
|
38
|
-
await generateReleaseSummary({
|
|
39
|
-
root,
|
|
40
|
-
component: planned.component,
|
|
41
|
-
version: planned.newVersion,
|
|
42
|
-
paths,
|
|
43
|
-
customSummary: request.summary,
|
|
44
|
-
outputFile: `.release-notes/final/${planned.component}-v${planned.newVersion}.md`,
|
|
45
|
-
fromRef,
|
|
46
|
-
toRef: options.toRef,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
for (const path of request.paths) rmSync(path);
|
|
50
|
-
logger.success(`generated ${plan.units.length} component release summaries`);
|
|
51
|
-
})
|
|
52
|
-
.parseAsync();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function requestNotes(root: string): { summary?: string; paths: string[] } {
|
|
56
|
-
const directory = resolve(root, ".release-notes/requests");
|
|
57
|
-
if (!existsSync(directory)) return { paths: [] };
|
|
58
|
-
const paths = readdirSync(directory)
|
|
59
|
-
.filter((name) => name.endsWith(".md"))
|
|
60
|
-
.sort()
|
|
61
|
-
.map((name) => join(directory, name));
|
|
62
|
-
const summary = paths
|
|
63
|
-
.map((path) =>
|
|
64
|
-
readFileSync(path, "utf8")
|
|
65
|
-
.replace(/^# .+\n+/, "")
|
|
66
|
-
.trim(),
|
|
67
|
-
)
|
|
68
|
-
.filter(Boolean)
|
|
69
|
-
.join("\n\n");
|
|
70
|
-
return { ...(summary ? { summary } : {}), paths };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function gitCapture(root: string, args: string[]): string {
|
|
74
|
-
return captureTaskCommand(root, "git", args);
|
|
75
|
-
}
|
package/tasks/release-summary.ts
DELETED
|
@@ -1,381 +0,0 @@
|
|
|
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 * as exec from "@dbx-tools/core/exec";
|
|
10
|
-
import * as projectUtils from "@dbx-tools/core/project-utils";
|
|
11
|
-
import { json, log, object, stringUtils } from "@dbx-tools/shared-core";
|
|
12
|
-
import { captureTaskCommand } from "../src/_task-command.ts";
|
|
13
|
-
import {
|
|
14
|
-
RELEASE_SUMMARY_PROVIDER_NAMES,
|
|
15
|
-
releaseSummaryFile,
|
|
16
|
-
type ReleaseSummaryProviderName,
|
|
17
|
-
} from "../src/release-dispatch.ts";
|
|
18
|
-
|
|
19
|
-
const logger = log.logger("projen:release-summary");
|
|
20
|
-
const PROVIDER_TIMEOUT_MS = 60_000;
|
|
21
|
-
const PROVIDER_KILL_GRACE_MS = 5_000;
|
|
22
|
-
|
|
23
|
-
export interface ReleaseSummaryProvider {
|
|
24
|
-
readonly name: ReleaseSummaryProviderName;
|
|
25
|
-
readonly command: string;
|
|
26
|
-
readonly probeArgs: readonly string[];
|
|
27
|
-
args(root: string, prompt: string): string[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const RELEASE_SUMMARY_PROVIDERS: readonly ReleaseSummaryProvider[] = [
|
|
31
|
-
{
|
|
32
|
-
name: "cursor",
|
|
33
|
-
command: "cursor",
|
|
34
|
-
probeArgs: ["agent", "--version"],
|
|
35
|
-
args: (root, prompt) => [
|
|
36
|
-
"agent",
|
|
37
|
-
"--print",
|
|
38
|
-
"--mode",
|
|
39
|
-
"ask",
|
|
40
|
-
"--output-format",
|
|
41
|
-
"stream-json",
|
|
42
|
-
"--trust",
|
|
43
|
-
"--workspace",
|
|
44
|
-
root,
|
|
45
|
-
prompt,
|
|
46
|
-
],
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: "codex",
|
|
50
|
-
command: "codex",
|
|
51
|
-
probeArgs: ["--version"],
|
|
52
|
-
args: (root, prompt) => [
|
|
53
|
-
"exec",
|
|
54
|
-
"--sandbox",
|
|
55
|
-
"read-only",
|
|
56
|
-
"--skip-git-repo-check",
|
|
57
|
-
"--json",
|
|
58
|
-
"--color",
|
|
59
|
-
"never",
|
|
60
|
-
"-C",
|
|
61
|
-
root,
|
|
62
|
-
prompt,
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: "claude",
|
|
67
|
-
command: "claude",
|
|
68
|
-
probeArgs: ["--version"],
|
|
69
|
-
args: (_root, prompt) => [
|
|
70
|
-
"--print",
|
|
71
|
-
"--no-session-persistence",
|
|
72
|
-
"--output-format",
|
|
73
|
-
"stream-json",
|
|
74
|
-
"--verbose",
|
|
75
|
-
"--tools",
|
|
76
|
-
"",
|
|
77
|
-
prompt,
|
|
78
|
-
],
|
|
79
|
-
},
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
export type ReleaseSummaryRunner = (
|
|
83
|
-
provider: ReleaseSummaryProvider,
|
|
84
|
-
root: string,
|
|
85
|
-
prompt: string,
|
|
86
|
-
) => string | undefined | Promise<string | undefined>;
|
|
87
|
-
|
|
88
|
-
function capture(root: string, command: string, args: string[]): string {
|
|
89
|
-
return captureTaskCommand(root, command, args);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function contentText(value: unknown): string | undefined {
|
|
93
|
-
if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
|
|
94
|
-
if (!Array.isArray(value)) return undefined;
|
|
95
|
-
const text = value
|
|
96
|
-
.flatMap((item) => (object.isRecord(item) && typeof item.text === "string" ? [item.text] : []))
|
|
97
|
-
.join("");
|
|
98
|
-
return stringUtils.trimToNull(text) ?? undefined;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function eventText(event: Record<string, unknown>): string | undefined {
|
|
102
|
-
if (typeof event.result === "string") return stringUtils.trimToNull(event.result) ?? undefined;
|
|
103
|
-
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
104
|
-
if (item?.type === "agent_message" && typeof item.text === "string") {
|
|
105
|
-
return stringUtils.trimToNull(item.text) ?? undefined;
|
|
106
|
-
}
|
|
107
|
-
const message = object.isRecord(event.message) ? event.message : undefined;
|
|
108
|
-
return contentText(message?.content);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function eventType(event: Record<string, unknown>): string {
|
|
112
|
-
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
113
|
-
return [event.type, item?.type].filter((value) => typeof value === "string").join(":") || "event";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function eventDetail(event: Record<string, unknown>): string | undefined {
|
|
117
|
-
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
118
|
-
for (const value of [item?.message, event.message, event.error]) {
|
|
119
|
-
if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
|
|
120
|
-
}
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function eventToolName(event: Record<string, unknown>): string | undefined {
|
|
125
|
-
const item = object.isRecord(event.item) ? event.item : undefined;
|
|
126
|
-
for (const value of [event.tool_name, event.toolName, event.name, item?.tool_name, item?.name]) {
|
|
127
|
-
if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
|
|
128
|
-
}
|
|
129
|
-
const toolCall = object.isRecord(event.tool_call)
|
|
130
|
-
? event.tool_call
|
|
131
|
-
: object.isRecord(event.toolCall)
|
|
132
|
-
? event.toolCall
|
|
133
|
-
: undefined;
|
|
134
|
-
const wrapper = toolCall
|
|
135
|
-
? Object.keys(toolCall).find(
|
|
136
|
-
(key) => key.endsWith("ToolCall") && object.isRecord(toolCall[key]),
|
|
137
|
-
)
|
|
138
|
-
: undefined;
|
|
139
|
-
return wrapper ? wrapper.slice(0, -"ToolCall".length) : undefined;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Normalize one provider event into the fields retained in release logs. */
|
|
143
|
-
export function releaseSummaryProviderEvent(event: Record<string, unknown>): {
|
|
144
|
-
type: string;
|
|
145
|
-
text?: string;
|
|
146
|
-
detail?: string;
|
|
147
|
-
tool?: string;
|
|
148
|
-
} {
|
|
149
|
-
const text = eventText(event);
|
|
150
|
-
const detail = eventDetail(event);
|
|
151
|
-
const tool = eventToolName(event);
|
|
152
|
-
return {
|
|
153
|
-
type: eventType(event),
|
|
154
|
-
...(text ? { text } : {}),
|
|
155
|
-
...(detail ? { detail } : {}),
|
|
156
|
-
...(tool ? { tool } : {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function runProvider(
|
|
161
|
-
provider: ReleaseSummaryProvider,
|
|
162
|
-
root: string,
|
|
163
|
-
prompt: string,
|
|
164
|
-
): Promise<string | undefined> {
|
|
165
|
-
if (!capture(root, provider.command, [...provider.probeArgs])) return undefined;
|
|
166
|
-
let summary: string | undefined;
|
|
167
|
-
let previousEventKey: string | undefined;
|
|
168
|
-
const textOutput: string[] = [];
|
|
169
|
-
const logEvent = (event: Record<string, unknown>) => {
|
|
170
|
-
const fields = { provider: provider.name, ...event };
|
|
171
|
-
const key = JSON.stringify(fields);
|
|
172
|
-
if (key === previousEventKey) return;
|
|
173
|
-
previousEventKey = key;
|
|
174
|
-
logger.info("provider-event", fields);
|
|
175
|
-
};
|
|
176
|
-
const child = exec.spawn(provider.command, provider.args(root, prompt), {
|
|
177
|
-
cwd: root,
|
|
178
|
-
stdout: [
|
|
179
|
-
"capture",
|
|
180
|
-
(line) => {
|
|
181
|
-
const event = json.parseRecord(line);
|
|
182
|
-
if (!event) {
|
|
183
|
-
const text = stringUtils.trimToNull(line);
|
|
184
|
-
if (text) {
|
|
185
|
-
textOutput.push(text);
|
|
186
|
-
logEvent({ type: "text", text });
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const fields = releaseSummaryProviderEvent(event);
|
|
191
|
-
if (fields.text) summary = fields.text;
|
|
192
|
-
logEvent(fields);
|
|
193
|
-
},
|
|
194
|
-
],
|
|
195
|
-
stderr: (line) => {
|
|
196
|
-
const message = stringUtils.trimToNull(line);
|
|
197
|
-
if (message) logger.warn("provider-stderr", { provider: provider.name, message });
|
|
198
|
-
},
|
|
199
|
-
stdin: "ignore",
|
|
200
|
-
check: false,
|
|
201
|
-
});
|
|
202
|
-
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
-
const timeout = setTimeout(() => {
|
|
204
|
-
logger.warn("provider-timeout", {
|
|
205
|
-
provider: provider.name,
|
|
206
|
-
timeoutMs: PROVIDER_TIMEOUT_MS,
|
|
207
|
-
});
|
|
208
|
-
child.kill("SIGTERM");
|
|
209
|
-
escalation = setTimeout(() => child.kill("SIGKILL"), PROVIDER_KILL_GRACE_MS);
|
|
210
|
-
escalation.unref();
|
|
211
|
-
}, PROVIDER_TIMEOUT_MS);
|
|
212
|
-
timeout.unref();
|
|
213
|
-
const result = await child.finally(() => {
|
|
214
|
-
clearTimeout(timeout);
|
|
215
|
-
if (escalation) clearTimeout(escalation);
|
|
216
|
-
});
|
|
217
|
-
if (result.exitCode !== 0) {
|
|
218
|
-
logger.warn("provider-failed", { provider: provider.name, exitCode: result.exitCode });
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
return summary ?? (textOutput.length ? textOutput.join("\n") : undefined);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Return the first non-empty provider response in Cursor, Codex, Claude order. */
|
|
225
|
-
export async function selectReleaseSummary(
|
|
226
|
-
root: string,
|
|
227
|
-
prompt: string,
|
|
228
|
-
runner: ReleaseSummaryRunner = runProvider,
|
|
229
|
-
providers: readonly ReleaseSummaryProviderName[] = RELEASE_SUMMARY_PROVIDER_NAMES,
|
|
230
|
-
): Promise<{ provider: ReleaseSummaryProvider["name"]; summary: string } | undefined> {
|
|
231
|
-
for (const name of providers) {
|
|
232
|
-
const provider = RELEASE_SUMMARY_PROVIDERS.find((candidate) => candidate.name === name);
|
|
233
|
-
if (!provider) continue;
|
|
234
|
-
const summary = (await runner(provider, root, prompt))?.trim();
|
|
235
|
-
if (summary) return { provider: provider.name, summary };
|
|
236
|
-
}
|
|
237
|
-
return undefined;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function summaryPrompt(
|
|
241
|
-
projectName: string,
|
|
242
|
-
version: string,
|
|
243
|
-
fromRef: string | undefined,
|
|
244
|
-
commits: string,
|
|
245
|
-
changedFiles: string,
|
|
246
|
-
diffStat: string,
|
|
247
|
-
): string {
|
|
248
|
-
return [
|
|
249
|
-
`Write a concise user-facing Markdown release summary for ${projectName} ${version}.`,
|
|
250
|
-
"Use only the supplied Git context. Do not run commands, edit files, speculate,",
|
|
251
|
-
"mention commit hashes, use emojis, or use em/en dashes.",
|
|
252
|
-
"Return no title and no fenced block. Start with one short paragraph, then",
|
|
253
|
-
"group concrete changes under brief Markdown headings with bullets.",
|
|
254
|
-
"",
|
|
255
|
-
`Comparison base: ${fromRef ?? "repository root"}`,
|
|
256
|
-
"",
|
|
257
|
-
"Commits:",
|
|
258
|
-
commits || "(none)",
|
|
259
|
-
"",
|
|
260
|
-
"Changed files:",
|
|
261
|
-
changedFiles || "(none)",
|
|
262
|
-
"",
|
|
263
|
-
"Diff stat:",
|
|
264
|
-
diffStat || "(none)",
|
|
265
|
-
].join("\n");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Deterministic fallback when every configured AI provider is unavailable. */
|
|
269
|
-
function gitSummary(
|
|
270
|
-
projectName: string,
|
|
271
|
-
version: string,
|
|
272
|
-
commits: string,
|
|
273
|
-
changedFiles: string,
|
|
274
|
-
): string {
|
|
275
|
-
const subjects = commits
|
|
276
|
-
.split("\n")
|
|
277
|
-
.map((line) => line.trim())
|
|
278
|
-
.filter(Boolean)
|
|
279
|
-
.filter((line, index, lines) => lines.indexOf(line) === index)
|
|
280
|
-
.slice(0, 20);
|
|
281
|
-
const files = changedFiles
|
|
282
|
-
.split("\n")
|
|
283
|
-
.map((line) => line.trim().split(/\s+/).at(-1))
|
|
284
|
-
.filter((value): value is string => Boolean(value));
|
|
285
|
-
const areas = files
|
|
286
|
-
.map((file) => {
|
|
287
|
-
if (file.startsWith("packages/")) return file.split("/").slice(0, 4).join("/");
|
|
288
|
-
return file.split("/")[0] ?? file;
|
|
289
|
-
})
|
|
290
|
-
.filter((area, index, all) => all.indexOf(area) === index)
|
|
291
|
-
.slice(0, 12);
|
|
292
|
-
const bullets = subjects.length ? subjects : areas.map((area) => `Updated ${area}`);
|
|
293
|
-
return [
|
|
294
|
-
`${projectName} ${version} contains the reviewed changes listed below.`,
|
|
295
|
-
"",
|
|
296
|
-
"## Changes",
|
|
297
|
-
...(bullets.length ? bullets.map((item) => `- ${item}`) : ["- Release metadata updated."]),
|
|
298
|
-
].join("\n");
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Generate one immutable versioned summary with an AI or Git fallback. */
|
|
302
|
-
export async function generateReleaseSummary(options: {
|
|
303
|
-
readonly root: string;
|
|
304
|
-
readonly version: string;
|
|
305
|
-
readonly component?: string;
|
|
306
|
-
readonly paths?: readonly string[];
|
|
307
|
-
readonly customSummary?: string;
|
|
308
|
-
readonly outputFile?: string;
|
|
309
|
-
readonly fromRef?: string;
|
|
310
|
-
readonly toRef?: string;
|
|
311
|
-
readonly providers?: readonly ReleaseSummaryProviderName[];
|
|
312
|
-
readonly runner?: ReleaseSummaryRunner;
|
|
313
|
-
}): Promise<string | undefined> {
|
|
314
|
-
const projectName = projectUtils.name(options.root);
|
|
315
|
-
const relativeOutput =
|
|
316
|
-
options.outputFile ?? releaseSummaryFile(options.version, options.component);
|
|
317
|
-
const output = join(options.root, relativeOutput);
|
|
318
|
-
if (existsSync(output)) rmSync(output);
|
|
319
|
-
const target =
|
|
320
|
-
options.toRef && capture(options.root, "git", ["rev-parse", "--verify", options.toRef])
|
|
321
|
-
? options.toRef
|
|
322
|
-
: "HEAD";
|
|
323
|
-
const range =
|
|
324
|
-
options.fromRef && capture(options.root, "git", ["rev-parse", "--verify", options.fromRef])
|
|
325
|
-
? `${options.fromRef}..${target}`
|
|
326
|
-
: target;
|
|
327
|
-
const scopedPaths = options.paths?.length ? [...options.paths] : ["."];
|
|
328
|
-
const commits = capture(options.root, "git", [
|
|
329
|
-
"log",
|
|
330
|
-
"--no-merges",
|
|
331
|
-
"--format=%s%n%b",
|
|
332
|
-
range,
|
|
333
|
-
"--",
|
|
334
|
-
...scopedPaths,
|
|
335
|
-
]);
|
|
336
|
-
const changedFiles = capture(options.root, "git", [
|
|
337
|
-
"diff",
|
|
338
|
-
"--name-status",
|
|
339
|
-
range,
|
|
340
|
-
"--",
|
|
341
|
-
...scopedPaths,
|
|
342
|
-
`:(exclude)${relativeOutput}`,
|
|
343
|
-
]);
|
|
344
|
-
const diffStat = capture(options.root, "git", [
|
|
345
|
-
"diff",
|
|
346
|
-
"--stat",
|
|
347
|
-
range,
|
|
348
|
-
"--",
|
|
349
|
-
...scopedPaths,
|
|
350
|
-
`:(exclude)${relativeOutput}`,
|
|
351
|
-
]);
|
|
352
|
-
const customSummary = stringUtils.trimToNull(options.customSummary);
|
|
353
|
-
const result = customSummary
|
|
354
|
-
? undefined
|
|
355
|
-
: await selectReleaseSummary(
|
|
356
|
-
options.root,
|
|
357
|
-
summaryPrompt(
|
|
358
|
-
projectName,
|
|
359
|
-
options.version,
|
|
360
|
-
options.fromRef,
|
|
361
|
-
commits,
|
|
362
|
-
changedFiles,
|
|
363
|
-
diffStat,
|
|
364
|
-
),
|
|
365
|
-
options.runner,
|
|
366
|
-
options.providers,
|
|
367
|
-
);
|
|
368
|
-
const summary =
|
|
369
|
-
customSummary ??
|
|
370
|
-
result?.summary ??
|
|
371
|
-
gitSummary(projectName, options.version, commits, changedFiles);
|
|
372
|
-
const provider = customSummary ? "custom" : (result?.provider ?? "git");
|
|
373
|
-
if (!customSummary && !result) {
|
|
374
|
-
logger.info("AI providers unavailable; using Git release summary");
|
|
375
|
-
}
|
|
376
|
-
const content = `# Release ${options.component ? `${options.component} ` : ""}${options.version}\n\n${summary.trim()}\n`;
|
|
377
|
-
mkdirSync(dirname(output), { recursive: true });
|
|
378
|
-
writeFileSync(output, content);
|
|
379
|
-
logger.info("generated", { provider, path: output });
|
|
380
|
-
return content;
|
|
381
|
-
}
|