-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathbuild-release-file.cjs
More file actions
123 lines (110 loc) · 5.89 KB
/
Copy pathbuild-release-file.cjs
File metadata and controls
123 lines (110 loc) · 5.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// Orchestrate one GitHub release into a rendered changelog file. Pure given
// injected deps (enrich + readExisting), so it's unit-testable without network.
const { tagToMeta, getPackageUrl } = require('./tag-meta.cjs')
const { parseNewContributors } = require('./parse-release-body.cjs')
const { renderMarkdown, mergePreserving } = require('./render-markdown.cjs')
function fileNameFor(sdk, language, version) {
if (sdk === 'evals') return `evals/v${version}.md`
return `harness/${language}-v${version}.md`
}
/**
* @param {string} repo the SOURCE repo the release belongs to
* @param {{tag_name:string, published_at:string, html_url:string, body:string|null}} release
* @param {{deriveEntries:(repo:string,release:object)=>Promise<{entries:Array,warning?:string}>, enrich:(prRepo:string,pr:number)=>Promise<{areas:string[],breaking:boolean,commit:string|null,author:string|null,languages:string[]|null,docsOnly:boolean}>, readExisting:(path:string)=>Promise<string|null>, skipExisting?:boolean}} deps
* @returns {Promise<{path:string, contents:string, warning?:string}|null>}
*/
async function buildReleaseFile(repo, release, deps) {
const meta = tagToMeta(repo, release.tag_name)
if (!meta) return null
const path = `site/src/content/changelog/${fileNameFor(meta.sdk, meta.language, meta.version)}`
const existing = await deps.readExisting(path)
// skipExisting (used by the daily cron backstop): only generate files for
// releases that don't have one yet. Checked BEFORE enrichment so a skipped
// release costs zero PR API calls, and existing files (possibly carrying
// richer enrichment from when labels were fresher) are never regressed by a
// rate-limited re-run. A full refresh is an explicit backfill dispatch.
if (deps.skipExisting && existing) return null
// Entries come from the GitHub compare API (every merged PR between the prior
// tag and this one) — deterministic and independent of release-note format.
// The release body is NOT parsed for entries; it's preserved as curated
// narrative via mergePreserving below.
const { entries: parsed, warning } = await deps.deriveEntries(repo, release)
// Two gates apply to every entry:
//
// 1. Docs-only (ALL streams): a PR confined to docs/blog/website dirs never
// lines up with an SDK+language, so it's dropped everywhere — including
// pre-monorepo bare-`v` and evals, which are otherwise unfiltered. This
// keeps the changelog focused on SDK+language work (a blog-only PR or a
// pure docs change won't appear in any stream).
// 2. Language (monorepo prefixed tags only): those releases list every merged
// PR regardless of language, so gate by which SDK dirs the PR touched —
// python stream keeps python-touching PRs, ts keeps ts-touching, both →
// both. Unknown file info → kept (degrade open).
//
// CRUCIAL: only gate when the PR has a POSITIVE dir signal — i.e. it
// touches strands-py/ and/or strands-ts/. A PR with EMPTY languages
// (touches neither: root config/CI, or a pre-monorepo flat-layout PR whose
// code lived under src/ before the strands-py/ dir existed) must be KEPT,
// not dropped. Gating on empty languages would wrongly empty pre-monorepo
// releases whose tags were re-applied as python/v* in the monorepo.
// Pre-monorepo bare-`v` and evals are single-language: no language gate.
const isMonorepoStream =
meta.sdk === 'harness' &&
(release.tag_name.startsWith('python/') || release.tag_name.startsWith('typescript/'))
// Shared keep/drop decision for both entries and new contributors: drop a
// docs-only PR from every stream, and on a monorepo stream drop a PR with a
// POSITIVE dir signal for the OTHER language (empty/unknown languages are
// kept — see the language-gate note above).
const dropFromStream = (enr) =>
enr.docsOnly ||
(isMonorepoStream && Array.isArray(enr.languages) && enr.languages.length > 0 && !enr.languages.includes(meta.language))
const entries = []
for (const p of parsed) {
const prRepo = p.prRepo || repo
const enr = p.pr
? await deps.enrich(prRepo, p.pr)
: { areas: [], breaking: false, commit: null, author: null, languages: null, docsOnly: false }
if (dropFromStream(enr)) continue
const breaking = p.breaking || enr.breaking
entries.push({
type: breaking && p.type === 'other' ? 'breaking' : p.type,
breaking,
scope: p.scope,
areas: enr.areas,
title: p.title,
pr: p.pr,
prUrl: p.pr ? `https://github.com/${prRepo}/pull/${p.pr}` : null,
commit: enr.commit,
commitUrl: enr.commit ? `https://github.com/${prRepo}/commit/${enr.commit}` : null,
author: enr.author || p.author,
})
}
// New contributors use the same keep/drop rule as entries (dropFromStream):
// a docs-only first PR doesn't belong in an SDK+language changelog, and a
// monorepo PR with a positive other-language signal is gated out — but a
// first PR touching no sdk dir (e.g. ci) or with unknown files is kept in
// both streams: people aren't noise.
const rawContributors = parseNewContributors(release.body)
const newContributors = []
for (const c of rawContributors) {
// Use the PR's own repo (mirrors the entries path) — first-contribution
// links can point at the pre-monorepo repos.
const enr = await deps.enrich(c.prRepo || repo, c.pr)
if (dropFromStream(enr)) continue
newContributors.push(c)
}
const file = {
sdk: meta.sdk,
language: meta.language,
version: meta.version,
tag: release.tag_name,
date: release.published_at.slice(0, 10),
releaseUrl: release.html_url,
packageUrl: getPackageUrl(meta.sdk, meta.language, meta.version),
entries,
newContributors,
}
const contents = existing ? mergePreserving(file, existing) : renderMarkdown(file)
return warning ? { path, contents, warning } : { path, contents }
}
module.exports = { buildReleaseFile }