@reelout/core 0.1.1 → 0.2.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/dist/analyzer.js +38 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/builder.js +52 -0
- package/dist/builder.js.map +1 -0
- package/dist/changelog.js +58 -0
- package/dist/changelog.js.map +1 -0
- package/dist/formatError-BXNdyRfu.js +23 -0
- package/dist/formatError-BXNdyRfu.js.map +1 -0
- package/dist/index.js +175 -1
- package/dist/index.js.map +1 -0
- package/dist/types-la_KkjCS.js +1 -0
- package/dist/types.js +2 -0
- package/dist/workflow-BoVmbvH6.js +2342 -0
- package/dist/workflow-BoVmbvH6.js.map +1 -0
- package/package.json +27 -3
- package/src/analyzer.ts +63 -0
- package/src/builder.ts +104 -0
- package/src/cache/TTLCache.ts +100 -0
- package/src/cache/index.ts +4 -0
- package/src/changelog.ts +97 -0
- package/src/config/const.ts +97 -0
- package/src/config/index.ts +11 -0
- package/src/config/isSemVerGitTag.ts +25 -0
- package/src/config/normalizeCommitFilter.ts +37 -0
- package/src/config/normalizeReleaseRule.ts +42 -0
- package/src/config/normalizeWorkspace.ts +34 -0
- package/src/config/resolvePlugin.ts +56 -0
- package/src/config/resolveReleaseTarget.ts +71 -0
- package/src/config/sanitizePrereleaseLabel.ts +18 -0
- package/src/git/branch/getCurrentGitBranch.ts +18 -0
- package/src/git/branch/index.ts +4 -0
- package/src/git/commit/createGitCommit.ts +29 -0
- package/src/git/commit/extractMentions.ts +34 -0
- package/src/git/commit/getCurrentGitCommit.ts +14 -0
- package/src/git/commit/getParsedCommit.ts +53 -0
- package/src/git/commit/index.ts +9 -0
- package/src/git/commit/parseCommit.ts +99 -0
- package/src/git/commit/parseGitPrettyFormat.ts +76 -0
- package/src/git/history/countGitHistory.ts +28 -0
- package/src/git/history/expandGitHistory.ts +108 -0
- package/src/git/history/index.ts +9 -0
- package/src/git/history/mergeGitHistory.ts +76 -0
- package/src/git/history/restoreFullGitHistory.ts +19 -0
- package/src/git/history/showGitHistory.ts +80 -0
- package/src/git/history/showParsedGitHistory.ts +30 -0
- package/src/git/index.ts +9 -0
- package/src/git/repository/ensureGitUserConfig.ts +31 -0
- package/src/git/repository/getGitConfigValue.ts +27 -0
- package/src/git/repository/getGitRemote.ts +20 -0
- package/src/git/repository/index.ts +10 -0
- package/src/git/repository/isGitShallowRepository.ts +21 -0
- package/src/git/repository/pushGitChanges.ts +25 -0
- package/src/git/repository/resolveGitRepository.ts +40 -0
- package/src/git/repository/withShallowLock.ts +62 -0
- package/src/git/revision/detectGitRelationship.ts +43 -0
- package/src/git/revision/index.ts +7 -0
- package/src/git/revision/isGitAncestor.ts +33 -0
- package/src/git/revision/readGitFile.ts +29 -0
- package/src/git/revision/resolveGitRevision.ts +23 -0
- package/src/git/tag/createGitTag.ts +25 -0
- package/src/git/tag/deleteGitTag.ts +17 -0
- package/src/git/tag/ensureGitTag.ts +63 -0
- package/src/git/tag/generateGitTagName.ts +43 -0
- package/src/git/tag/getCurrentGitTag.ts +23 -0
- package/src/git/tag/getGitTagMessage.ts +35 -0
- package/src/git/tag/getLatestGitTag.ts +22 -0
- package/src/git/tag/index.ts +13 -0
- package/src/git/tag/isGitTagConnected.ts +33 -0
- package/src/git/tag/isGitTagPointed.ts +38 -0
- package/src/git/tag/isGitTagPresent.ts +17 -0
- package/src/index.ts +8 -2
- package/src/types.ts +217 -0
- package/src/util/buildTrailerPattern.ts +20 -0
- package/src/util/compact.ts +14 -0
- package/src/util/execCommand.ts +130 -0
- package/src/util/execGit.ts +18 -0
- package/src/util/formatError.ts +21 -0
- package/src/util/formatFile.ts +41 -0
- package/src/util/index.ts +16 -0
- package/src/util/interpolate.ts +141 -0
- package/src/util/isCI.ts +23 -0
- package/src/util/isDefined.ts +12 -0
- package/src/util/isMatch.ts +43 -0
- package/src/util/readJson.ts +47 -0
- package/src/util/unique.ts +26 -0
- package/src/util/writeJson.ts +28 -0
- package/src/workflow/analysis/analyzePackageChanges.ts +54 -0
- package/src/workflow/analysis/index.ts +9 -0
- package/src/workflow/analysis/isCommitForPackage.ts +23 -0
- package/src/workflow/analysis/promotePrereleaseVersions.ts +50 -0
- package/src/workflow/analysis/propagateDependencyUpdates.ts +71 -0
- package/src/workflow/analysis/sliceUnreleased.ts +36 -0
- package/src/workflow/analysis/streamPackageCommits.ts +34 -0
- package/src/workflow/changelog/buildChangelog.ts +79 -0
- package/src/workflow/changelog/buildReleaseNotes.ts +50 -0
- package/src/workflow/changelog/extractLatestVersion.ts +22 -0
- package/src/workflow/changelog/extractPlainText.ts +38 -0
- package/src/workflow/changelog/extractPreviousVersion.ts +77 -0
- package/src/workflow/changelog/extractSemVer.ts +33 -0
- package/src/workflow/changelog/index.ts +12 -0
- package/src/workflow/changelog/iterateChangelogVersions.ts +70 -0
- package/src/workflow/changelog/prependChangelog.ts +59 -0
- package/src/workflow/changelog/writeChangelog.ts +27 -0
- package/src/workflow/committing/generateReleaseCommitMessage.ts +96 -0
- package/src/workflow/committing/index.ts +5 -0
- package/src/workflow/committing/isReleaseCommit.ts +52 -0
- package/src/workflow/discovery/buildDependencyGraph.ts +43 -0
- package/src/workflow/discovery/discoverPackageTag.ts +98 -0
- package/src/workflow/discovery/index.ts +9 -0
- package/src/workflow/discovery/resolvePackageStableTag.ts +88 -0
- package/src/workflow/discovery/resolvePackageTag.ts +45 -0
- package/src/workflow/discovery/sortPackages.ts +73 -0
- package/src/workflow/discovery/verifyPackageTag.ts +43 -0
- package/src/workflow/index.ts +10 -0
- package/src/workflow/report/generateReleaseReport.ts +76 -0
- package/src/workflow/report/index.ts +5 -0
- package/src/workflow/report/printReleaseReport.ts +97 -0
- package/src/workflow/runner/index.ts +4 -0
- package/src/workflow/runner/runWorkflow.ts +120 -0
- package/src/workflow/versioning/bumpVersion.ts +67 -0
- package/src/workflow/versioning/deriveReleaseType.ts +33 -0
- package/src/workflow/versioning/determineReleaseType.ts +35 -0
- package/src/workflow/versioning/index.ts +8 -0
- package/src/workflow/versioning/isStableVersion.ts +31 -0
- package/src/workflow/versioning/pickPriorityReleaseType.ts +27 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { colors } from 'consola/utils'
|
|
5
|
+
import fs from 'node:fs/promises'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read and parse a JSON file.
|
|
9
|
+
*
|
|
10
|
+
* Throws a descriptive error when the file cannot be read or parsed, or when
|
|
11
|
+
* the parsed value is not an object.
|
|
12
|
+
*
|
|
13
|
+
* @param filePath Path to the JSON file
|
|
14
|
+
* @returns Parsed JSON object
|
|
15
|
+
* @throws If the file cannot be read, parsed, or is not an object
|
|
16
|
+
*/
|
|
17
|
+
export async function readJson<T>(filePath: string): Promise<T> {
|
|
18
|
+
let content: string
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
content = await fs.readFile(filePath, 'utf-8')
|
|
22
|
+
} catch (err) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`JSON file at "${colors.bold(filePath)}" could not be read.`,
|
|
25
|
+
{ cause: err }
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let json: T
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
json = JSON.parse(content) as T
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`JSON file at "${colors.bold(filePath)}" could not be parsed.`,
|
|
36
|
+
{ cause: err }
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof json !== 'object' || json === null) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`JSON file at "${colors.bold(filePath)}" does not contain a valid object.`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return json
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Return a new array containing only the first-seen occurrence of each item.
|
|
6
|
+
*
|
|
7
|
+
* This preserves the original iteration order and performs equality checks
|
|
8
|
+
* using JavaScript's `Set` semantics (i.e., primitives by value, objects by
|
|
9
|
+
* reference). The input array is not mutated.
|
|
10
|
+
*
|
|
11
|
+
* @param array The array of items to deduplicate
|
|
12
|
+
* @returns A new array with duplicates removed, preserving first-seen order
|
|
13
|
+
*/
|
|
14
|
+
export function unique<T>(array: readonly T[]): T[] {
|
|
15
|
+
const seen = new Set<T>()
|
|
16
|
+
const result: T[] = []
|
|
17
|
+
|
|
18
|
+
for (const item of array) {
|
|
19
|
+
if (!seen.has(item)) {
|
|
20
|
+
seen.add(item)
|
|
21
|
+
result.push(item)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { formatFile } from './formatFile'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Write a JSON `payload` to `filePath`, formatting the content first.
|
|
10
|
+
*
|
|
11
|
+
* Ensures the directory exists and writes the formatted JSON.
|
|
12
|
+
*
|
|
13
|
+
* @param filePath Path to write the JSON file
|
|
14
|
+
* @param payload JSON-serializable object to write
|
|
15
|
+
* @returns Promise that resolves when the file is written
|
|
16
|
+
*/
|
|
17
|
+
export async function writeJson(
|
|
18
|
+
filePath: string,
|
|
19
|
+
payload: object
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
let content = JSON.stringify(payload, null, 2)
|
|
22
|
+
|
|
23
|
+
content = await formatFile(filePath, content)
|
|
24
|
+
|
|
25
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
26
|
+
|
|
27
|
+
await fs.writeFile(filePath, content)
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { Commit, CommitClassifier, ReleasePlan } from '#types'
|
|
5
|
+
import { deriveReleaseType, pickPriorityReleaseType } from '../versioning'
|
|
6
|
+
import { isCommitForPackage } from './isCommitForPackage'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Analyze commit history and compute release types for each package plan.
|
|
10
|
+
*
|
|
11
|
+
* Iterates through commits, associates them with affected packages, and
|
|
12
|
+
* calculates each package's release type using the provided classifier.
|
|
13
|
+
*
|
|
14
|
+
* @param classifier Function mapping commits to release types
|
|
15
|
+
* @param plans Array of release plans to update
|
|
16
|
+
* @param history Commit history as iterable or async iterable
|
|
17
|
+
* @returns Updated array of plans with computed `releaseType`
|
|
18
|
+
*/
|
|
19
|
+
export async function analyzePackageChanges(
|
|
20
|
+
classifier: CommitClassifier,
|
|
21
|
+
plans: readonly ReleasePlan[],
|
|
22
|
+
history: Iterable<Commit> | AsyncIterable<Commit>
|
|
23
|
+
): Promise<ReleasePlan[]> {
|
|
24
|
+
for await (const commit of history) {
|
|
25
|
+
plans = plans.map((plan): ReleasePlan => {
|
|
26
|
+
if (!isCommitForPackage(plan.package, commit)) {
|
|
27
|
+
return plan
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...plan,
|
|
32
|
+
history: plan.history.concat(commit),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return plans.map((plan): ReleasePlan => {
|
|
38
|
+
let { release } = plan
|
|
39
|
+
|
|
40
|
+
const releaseType = pickPriorityReleaseType(
|
|
41
|
+
release?.type,
|
|
42
|
+
deriveReleaseType(classifier, plan.history)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (releaseType) {
|
|
46
|
+
release = { ...release, type: releaseType }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...plan,
|
|
51
|
+
release,
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
export * from './analyzePackageChanges'
|
|
5
|
+
export * from './isCommitForPackage'
|
|
6
|
+
export * from './promotePrereleaseVersions'
|
|
7
|
+
export * from './propagateDependencyUpdates'
|
|
8
|
+
export * from './sliceUnreleased'
|
|
9
|
+
export * from './streamPackageCommits'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { ALL_FILES } from '#config'
|
|
5
|
+
import type { Commit, Package } from '#types'
|
|
6
|
+
import { isMatch } from '#util'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Test whether a commit modifies files in a package.
|
|
11
|
+
*
|
|
12
|
+
* @param pkg Package with an explicit file pattern or base directory
|
|
13
|
+
* @param commit Parsed commit containing changed file paths
|
|
14
|
+
* @returns `true` if any changed file matches the package pattern
|
|
15
|
+
*/
|
|
16
|
+
export function isCommitForPackage(pkg: Package, commit: Commit): boolean {
|
|
17
|
+
return commit.files.some((file) => {
|
|
18
|
+
const { dir } = pkg
|
|
19
|
+
const files = pkg.files || path.join(dir, ALL_FILES)
|
|
20
|
+
|
|
21
|
+
return isMatch(file, files)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { ReleasePlan, ReleaseType } from '#types'
|
|
5
|
+
import semver from 'semver'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Promote prerelease versions when they have no explicit release type.
|
|
9
|
+
*
|
|
10
|
+
* Analyzes prerelease and zero-major versions to determine an appropriate
|
|
11
|
+
* release type for graduation:
|
|
12
|
+
* - Prerelease versions are promoted based on their highest incremented component
|
|
13
|
+
* (patch, minor, or major)
|
|
14
|
+
* - Zero-major versions (0.y.z) are promoted to major
|
|
15
|
+
* - Stable versions (1.0.0+) remain unchanged
|
|
16
|
+
* - Plans with explicit releases are preserved as-is
|
|
17
|
+
*
|
|
18
|
+
* @param plans Release plans to analyze
|
|
19
|
+
* @returns Plans with inferred release types for prerelease versions
|
|
20
|
+
*/
|
|
21
|
+
export async function promotePrereleaseVersions(
|
|
22
|
+
plans: readonly ReleasePlan[]
|
|
23
|
+
): Promise<ReleasePlan[]> {
|
|
24
|
+
return plans.map((plan): ReleasePlan => {
|
|
25
|
+
if (plan.release) return plan
|
|
26
|
+
|
|
27
|
+
if (!plan.package.tag) return plan
|
|
28
|
+
|
|
29
|
+
const parsed = semver.parse(plan.package.version)
|
|
30
|
+
|
|
31
|
+
if (!parsed) return plan
|
|
32
|
+
|
|
33
|
+
let type: ReleaseType | undefined
|
|
34
|
+
|
|
35
|
+
if (parsed.prerelease.length) {
|
|
36
|
+
if (parsed.patch !== 0) type = 'patch'
|
|
37
|
+
else if (parsed.minor !== 0) type = 'minor'
|
|
38
|
+
else if (parsed.major !== 0) type = 'major'
|
|
39
|
+
else type = 'major'
|
|
40
|
+
} else if (parsed.major === 0) {
|
|
41
|
+
type = 'major'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (type) {
|
|
45
|
+
return { ...plan, release: { type } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return plan
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { mergeGitHistory } from '#git'
|
|
5
|
+
import type { Commit, ReleasePlan } from '#types'
|
|
6
|
+
import { buildDependencyGraph } from '../discovery'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Propagate release updates through package dependencies.
|
|
10
|
+
*
|
|
11
|
+
* Marks packages that depend on a releasing package for a `patch` release
|
|
12
|
+
* when they do not already have a release type.
|
|
13
|
+
*
|
|
14
|
+
* @param plans Array of release plans to update
|
|
15
|
+
* @returns Updated array of release plans
|
|
16
|
+
*/
|
|
17
|
+
export async function propagateDependencyUpdates(
|
|
18
|
+
cwd: string,
|
|
19
|
+
plans: readonly ReleasePlan[]
|
|
20
|
+
): Promise<ReleasePlan[]> {
|
|
21
|
+
const graph = buildDependencyGraph(plans)
|
|
22
|
+
|
|
23
|
+
const scheduled = new Map<ReleasePlan, Commit[]>()
|
|
24
|
+
|
|
25
|
+
const stack: ReleasePlan[] = []
|
|
26
|
+
|
|
27
|
+
for (const plan of plans) {
|
|
28
|
+
if (plan.release?.type) {
|
|
29
|
+
const visited = new Set([plan])
|
|
30
|
+
|
|
31
|
+
stack.push(plan)
|
|
32
|
+
|
|
33
|
+
let curr: ReleasePlan | undefined
|
|
34
|
+
|
|
35
|
+
while ((curr = stack.pop())) {
|
|
36
|
+
if (curr.package.name) {
|
|
37
|
+
for (const dependent of graph.get(curr.package.name) ?? []) {
|
|
38
|
+
if (!visited.has(dependent)) {
|
|
39
|
+
visited.add(dependent)
|
|
40
|
+
|
|
41
|
+
stack.push(dependent)
|
|
42
|
+
|
|
43
|
+
scheduled.set(
|
|
44
|
+
dependent,
|
|
45
|
+
await mergeGitHistory(
|
|
46
|
+
cwd,
|
|
47
|
+
scheduled.get(dependent) ?? dependent.history,
|
|
48
|
+
plan.history
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return plans.map((plan) => {
|
|
59
|
+
const history = scheduled.get(plan)
|
|
60
|
+
|
|
61
|
+
if (history) {
|
|
62
|
+
plan = { ...plan, history }
|
|
63
|
+
|
|
64
|
+
if (!plan.release?.type) {
|
|
65
|
+
plan.release = { type: 'patch' }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return plan
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { showParsedGitHistory, type ShowGitHistoryOptions } from '#git'
|
|
5
|
+
import type { Commit } from '#types'
|
|
6
|
+
import {
|
|
7
|
+
isReleaseCommit,
|
|
8
|
+
type ReleaseMarker,
|
|
9
|
+
} from '../committing/isReleaseCommit'
|
|
10
|
+
|
|
11
|
+
export interface SliceUnreleasedOptions extends ShowGitHistoryOptions {
|
|
12
|
+
marker?: ReleaseMarker | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Yield parsed commits from the workspace until a release commit is found.
|
|
17
|
+
*
|
|
18
|
+
* Iterates through parsed commit history and yields unreleased commits in
|
|
19
|
+
* order. Stops processing upon reaching the first release commit.
|
|
20
|
+
*
|
|
21
|
+
* @param cwd Path to the working directory
|
|
22
|
+
* @param options Options for configuring Git history retrieval
|
|
23
|
+
* @yields Parsed `Commit` objects up to the most recent release commit
|
|
24
|
+
*/
|
|
25
|
+
export async function* sliceUnreleased(
|
|
26
|
+
cwd: string,
|
|
27
|
+
options?: SliceUnreleasedOptions
|
|
28
|
+
): AsyncGenerator<Commit> {
|
|
29
|
+
for await (const commit of showParsedGitHistory(cwd, options)) {
|
|
30
|
+
if (await isReleaseCommit(cwd, commit, options?.marker)) {
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
yield commit
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { showParsedGitHistory, type ShowGitHistoryOptions } from '#git'
|
|
5
|
+
import type { Commit, Package } from '#types'
|
|
6
|
+
import { isCommitForPackage } from './isCommitForPackage'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Yield commits from git history associated with a package.
|
|
10
|
+
*
|
|
11
|
+
* Filters git commit history to stream only entries relevant to the target
|
|
12
|
+
* `pkg` directory.
|
|
13
|
+
*
|
|
14
|
+
* @param cwd Path to the working directory for git commands
|
|
15
|
+
* @param pkg Package configuration containing the target directory
|
|
16
|
+
* @param options Options for parsing and filtering git history
|
|
17
|
+
* @yields Commits belonging to the specified package
|
|
18
|
+
*/
|
|
19
|
+
export async function* streamPackageCommits(
|
|
20
|
+
cwd: string,
|
|
21
|
+
pkg: Package,
|
|
22
|
+
options: ShowGitHistoryOptions = {}
|
|
23
|
+
): AsyncGenerator<Commit> {
|
|
24
|
+
options = {
|
|
25
|
+
...options,
|
|
26
|
+
filePath: options.filePath || pkg.dir,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for await (const commit of showParsedGitHistory(cwd, options)) {
|
|
30
|
+
if (isCommitForPackage(pkg, commit)) {
|
|
31
|
+
yield commit
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CHANGELOG_FILE } from '#config'
|
|
5
|
+
import type { Commit, ReleaseContext } from '#types'
|
|
6
|
+
import { formatFile } from '#util'
|
|
7
|
+
import createPreset from 'conventional-changelog-conventionalcommits'
|
|
8
|
+
import {
|
|
9
|
+
writeChangelogString,
|
|
10
|
+
type CommitKnownProps,
|
|
11
|
+
type FinalTemplateContext,
|
|
12
|
+
type TemplateContext,
|
|
13
|
+
} from 'conventional-changelog-writer'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
export interface BuildChangelogOptions {
|
|
17
|
+
packageDir?: string | undefined
|
|
18
|
+
changelogFile?: string | undefined
|
|
19
|
+
version?: string | null | undefined
|
|
20
|
+
previousTag?: string | null | undefined
|
|
21
|
+
currentTag?: string | null | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const preset = await createPreset()
|
|
25
|
+
|
|
26
|
+
const headerPartial = (context: FinalTemplateContext) => {
|
|
27
|
+
return context.version ? preset.writer.headerPartial?.(context) : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the changelog string for a release.
|
|
32
|
+
*
|
|
33
|
+
* Compiles commit history and release context into a formatted changelog using
|
|
34
|
+
* conventional commits and writer configurations. Resolves file paths and handles
|
|
35
|
+
* tag comparisons when repository URLs are available.
|
|
36
|
+
*
|
|
37
|
+
* @param context The release context containing repository and working directory details
|
|
38
|
+
* @param history The iterable or async iterable collection of commits to process
|
|
39
|
+
* @param options The build configuration options for the changelog
|
|
40
|
+
* @returns The formatted changelog content string
|
|
41
|
+
*/
|
|
42
|
+
export async function buildChangelog(
|
|
43
|
+
context: ReleaseContext,
|
|
44
|
+
history: Iterable<Commit> | AsyncIterable<Commit>,
|
|
45
|
+
options: BuildChangelogOptions = {}
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const repository = context.repository?.url || undefined
|
|
48
|
+
|
|
49
|
+
const version = options.version || undefined
|
|
50
|
+
|
|
51
|
+
const previousTag = options.previousTag || undefined
|
|
52
|
+
|
|
53
|
+
const currentTag = options.currentTag || undefined
|
|
54
|
+
|
|
55
|
+
const filePath = path.join(
|
|
56
|
+
context.cwd,
|
|
57
|
+
options.packageDir || '.',
|
|
58
|
+
options.changelogFile || DEFAULT_CHANGELOG_FILE
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const content = await writeChangelogString<CommitKnownProps>(
|
|
62
|
+
history,
|
|
63
|
+
{
|
|
64
|
+
date: context.today,
|
|
65
|
+
version,
|
|
66
|
+
previousTag,
|
|
67
|
+
currentTag,
|
|
68
|
+
linkCompare: !!repository && !!previousTag && !!currentTag,
|
|
69
|
+
linkReferences: !!repository,
|
|
70
|
+
repoUrl: repository,
|
|
71
|
+
commit: 'commit',
|
|
72
|
+
issue: 'issues',
|
|
73
|
+
headerPartial,
|
|
74
|
+
} as TemplateContext,
|
|
75
|
+
preset.writer
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return formatFile(filePath, content)
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CHANGELOG_FILE } from '#config'
|
|
5
|
+
import type { Commit, ReleaseContext } from '#types'
|
|
6
|
+
import { formatFile } from '#util'
|
|
7
|
+
import type { Heading, RootContent } from 'mdast'
|
|
8
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
9
|
+
import { toMarkdown } from 'mdast-util-to-markdown'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { buildChangelog } from './buildChangelog'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build release notes from a release context and commit history.
|
|
15
|
+
*
|
|
16
|
+
* Generates changelog content from commits, parses it into an abstract syntax
|
|
17
|
+
* tree, shifts heading depths, and formats the resulting markdown file.
|
|
18
|
+
*
|
|
19
|
+
* @param context The release context containing repository and configuration details
|
|
20
|
+
* @param history The iterable or async iterable collection of commits to process
|
|
21
|
+
* @returns The formatted release notes as a markdown string
|
|
22
|
+
*/
|
|
23
|
+
export async function buildReleaseNotes(
|
|
24
|
+
context: ReleaseContext,
|
|
25
|
+
history: Iterable<Commit> | AsyncIterable<Commit>
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const changelogContent = await buildChangelog(context, history)
|
|
28
|
+
|
|
29
|
+
const root = fromMarkdown(changelogContent)
|
|
30
|
+
|
|
31
|
+
root.children = root.children.flatMap<RootContent>((node) => {
|
|
32
|
+
if (node.type === 'heading') {
|
|
33
|
+
/* istanbul ignore next */
|
|
34
|
+
if (node.depth <= 2) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
node = {
|
|
39
|
+
...node,
|
|
40
|
+
depth: (node.depth - 1) as Heading['depth'],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return node
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const filePath = path.join(context.cwd, DEFAULT_CHANGELOG_FILE)
|
|
48
|
+
|
|
49
|
+
return formatFile(filePath, toMarkdown(root))
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { iterateChangelogVersions } from './iterateChangelogVersions'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find the highest semantic version from the first version-bearing element.
|
|
8
|
+
*
|
|
9
|
+
* Pulls the first available version string from the parsed markdown iterator.
|
|
10
|
+
* Short-circuits execution immediately upon locating a match to avoid passing
|
|
11
|
+
* through the remaining document structure.
|
|
12
|
+
*
|
|
13
|
+
* @param changelogContent The raw markdown string representing the changelog
|
|
14
|
+
* @returns The highest valid semantic version string found or null
|
|
15
|
+
*/
|
|
16
|
+
export function extractLatestVersion(changelogContent: string): string | null {
|
|
17
|
+
for (const version of iterateChangelogVersions(changelogContent)) {
|
|
18
|
+
return version
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import type { Heading, Paragraph, PhrasingContent } from 'mdast'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract trimmed plain text from a Heading, Paragraph, or PhrasingContent node.
|
|
8
|
+
*
|
|
9
|
+
* Performs a depth-first traversal, concatenating string `value` tokens in
|
|
10
|
+
* document order. Non-string values are ignored. Returns `null` for empty or
|
|
11
|
+
* missing input.
|
|
12
|
+
*
|
|
13
|
+
* @param node Node to extract text from
|
|
14
|
+
* @returns The trimmed plain-text string, or null if empty
|
|
15
|
+
*/
|
|
16
|
+
export function extractPlainText(
|
|
17
|
+
node: Heading | Paragraph | PhrasingContent | null | undefined
|
|
18
|
+
): string | null {
|
|
19
|
+
if (!node) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const queue = [node]
|
|
24
|
+
let result = ''
|
|
25
|
+
|
|
26
|
+
while ((node = queue.shift())) {
|
|
27
|
+
if ('value' in node && typeof node.value === 'string') {
|
|
28
|
+
result += node.value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* istanbul ignore else */
|
|
32
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
33
|
+
queue.unshift(...node.children)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result.trim() || null
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { colors } from 'consola/utils'
|
|
5
|
+
import semver from 'semver'
|
|
6
|
+
import { iterateChangelogVersions } from './iterateChangelogVersions'
|
|
7
|
+
|
|
8
|
+
// TODO: remove unused
|
|
9
|
+
/**
|
|
10
|
+
* Find the latest stable version that precedes the current version.
|
|
11
|
+
*
|
|
12
|
+
* Parses markdown from top to bottom to extract semantic versions from headings
|
|
13
|
+
* and paragraphs. Enforces that discovered versions appear in strict
|
|
14
|
+
* descending order. Returns the first version that falls outside the current
|
|
15
|
+
* version's major, minor, and patch boundaries if the current version is
|
|
16
|
+
* `v1.0.0` or higher, or the first non-matching version for `v0.x.x` releases.
|
|
17
|
+
*
|
|
18
|
+
* @param currentVersion The version to evaluate against
|
|
19
|
+
* @param changelogContent The raw markdown content to scan for versions
|
|
20
|
+
* @returns The resolved stable version string or null when none is found
|
|
21
|
+
* @throws If `currentVersion` is invalid or already stable, or if versions in the changelog are not listed in strict descending order
|
|
22
|
+
*/
|
|
23
|
+
export async function extractPreviousVersion(
|
|
24
|
+
currentVersion: string | null | undefined,
|
|
25
|
+
changelogContent: string
|
|
26
|
+
): Promise<string | null> {
|
|
27
|
+
const parsedCurrent = semver.parse(currentVersion)
|
|
28
|
+
|
|
29
|
+
if (!currentVersion || !parsedCurrent) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`The current version "${colors.bold(String(currentVersion))}" is not a valid semantic version.`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const currentMajor = parsedCurrent.major
|
|
36
|
+
|
|
37
|
+
if (currentMajor >= 1 && parsedCurrent.prerelease.length === 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`The current version "${colors.bold(currentVersion)}" is already a stable release.`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const baseVersion = `${parsedCurrent.major}.${parsedCurrent.minor}.${parsedCurrent.patch}`
|
|
44
|
+
|
|
45
|
+
let priorVersion: semver.SemVer | null = null
|
|
46
|
+
|
|
47
|
+
for (const version of iterateChangelogVersions(changelogContent)) {
|
|
48
|
+
const parsedVersion = semver.parse(version)
|
|
49
|
+
|
|
50
|
+
/* istanbul ignore else */
|
|
51
|
+
if (parsedVersion) {
|
|
52
|
+
if (!priorVersion) {
|
|
53
|
+
if (version !== currentVersion) {
|
|
54
|
+
return version
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
priorVersion = parsedVersion
|
|
58
|
+
} else {
|
|
59
|
+
if (semver.gte(parsedVersion, priorVersion)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Each newer release must have a higher version, but "${colors.bold(priorVersion.version)}" appears before "${colors.bold(version)}".`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
priorVersion = parsedVersion
|
|
66
|
+
|
|
67
|
+
const { major, minor, patch } = parsedVersion
|
|
68
|
+
|
|
69
|
+
if (currentMajor >= 1 && `${major}.${minor}.${patch}` !== baseVersion) {
|
|
70
|
+
return version
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Fai
|
|
3
|
+
|
|
4
|
+
import { SEMVER_REGEX } from '#config'
|
|
5
|
+
import { compact } from '#util'
|
|
6
|
+
|
|
7
|
+
const ANCHORED_SEMVER_REGEX = new RegExp(`^${SEMVER_REGEX.source}$`)
|
|
8
|
+
|
|
9
|
+
const BRACKETED_SEMVER_REGEX = new RegExp(`\\[${SEMVER_REGEX.source}\\]`)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract SemVer core strings from a line.
|
|
13
|
+
*
|
|
14
|
+
* Splits the line on runs of spaces or tabs and on `..`/`...`. For each token,
|
|
15
|
+
* first attempts an anchored SemVer match (must match the whole token), then
|
|
16
|
+
* a bracketed match that finds a SemVer inside `[...]`. Returns the captured
|
|
17
|
+
* SemVer core (capture group 2) for each successful match; falsy results are
|
|
18
|
+
* filtered out.
|
|
19
|
+
*
|
|
20
|
+
* @param line Line to scan for SemVer cores
|
|
21
|
+
* @returns Array of extracted SemVer core strings; empty if none
|
|
22
|
+
*/
|
|
23
|
+
export function extractSemVer(line: string): string[] {
|
|
24
|
+
const tokens = line.split(/[ \t]+|\.{2,3}/)
|
|
25
|
+
|
|
26
|
+
return compact(
|
|
27
|
+
tokens.map((token) => {
|
|
28
|
+
return (
|
|
29
|
+
ANCHORED_SEMVER_REGEX.exec(token) ?? BRACKETED_SEMVER_REGEX.exec(token)
|
|
30
|
+
)?.at(2)
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
}
|