mirror of
https://github.com/desktop/desktop
synced 2024-10-31 05:19:03 +00:00
181 lines
4.8 KiB
TypeScript
181 lines
4.8 KiB
TypeScript
import * as Path from 'path'
|
|
import * as Fs from 'fs'
|
|
import { gt as greaterThan } from 'semver'
|
|
|
|
import { fetchPR, IAPIPR } from '../pr-api'
|
|
|
|
const PlaceholderChangeType = '???'
|
|
const OfficialOwner = 'desktop'
|
|
|
|
const ChangelogEntryRegex = /^\[(new|fixed|improved|removed|added)\]\s(.*)/i
|
|
|
|
interface IParsedCommit {
|
|
readonly prID: number
|
|
readonly owner: string
|
|
}
|
|
|
|
function parseCommitTitle(line: string): IParsedCommit {
|
|
// E.g.: Merge pull request #2424 from desktop/fix-shrinkwrap-file
|
|
const re = /^Merge pull request #(\d+) from (.+?)\/.*$/
|
|
const matches = line.match(re)
|
|
if (!matches || matches.length !== 3) {
|
|
throw new Error(`Unable to parse '${line}'`)
|
|
}
|
|
|
|
const id = parseInt(matches[1], 10)
|
|
if (isNaN(id)) {
|
|
throw new Error(`Unable to parse PR number from '${line}': ${matches[1]}`)
|
|
}
|
|
|
|
return {
|
|
prID: id,
|
|
owner: matches[2],
|
|
}
|
|
}
|
|
|
|
function capitalized(str: string): string {
|
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
}
|
|
|
|
/**
|
|
* Finds a release note in the PR body, which is under the 'Release notes'
|
|
* section, preceded by a 'Notes:' title.
|
|
*
|
|
* @param body Body of the PR to parse
|
|
* @returns The release note if it exist, null if it's explicitly marked to
|
|
* not have a release note (with no-notes), and undefined if there
|
|
* is no 'Release notes' section at all.
|
|
*/
|
|
export function findReleaseNote(body: string): string | null | undefined {
|
|
const re = /^Notes: (.+)$/gm
|
|
let lastMatches = null
|
|
|
|
// There might be multiple lines starting with "Notes: ", but we're only
|
|
// interested in the last one.
|
|
let matches = re.exec(body)
|
|
while (matches) {
|
|
lastMatches = matches
|
|
matches = re.exec(body)
|
|
}
|
|
|
|
if (!lastMatches || lastMatches.length < 2) {
|
|
return undefined
|
|
}
|
|
|
|
const note = lastMatches[1].replace(/\.$/, '')
|
|
return note === 'no-notes' ? null : note
|
|
}
|
|
|
|
export function findIssueRef(body: string): string {
|
|
let issueRef = ''
|
|
|
|
const re = /(close[s]?|fix(e[sd])?|resolve[sd]):?\s*#(\d+)/gi
|
|
let match: RegExpExecArray | null = null
|
|
do {
|
|
match = re.exec(body)
|
|
if (match && match.length === 4) {
|
|
// a match should always have four elements - the matching text
|
|
// as well as the three groups within the match. We're only
|
|
// interested in the last group - the issue reference number
|
|
issueRef += ` #${match[3]}`
|
|
}
|
|
} while (match)
|
|
|
|
return issueRef
|
|
}
|
|
|
|
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string | null {
|
|
let attribution = ''
|
|
if (commit.owner !== OfficialOwner) {
|
|
attribution = `. Thanks @${commit.owner}!`
|
|
}
|
|
|
|
let type = PlaceholderChangeType
|
|
const description = capitalized(pr.title)
|
|
|
|
let issueRef = findIssueRef(pr.body)
|
|
|
|
if (issueRef.length) {
|
|
type = 'Fixed'
|
|
} else {
|
|
issueRef = ` #${commit.prID}`
|
|
}
|
|
|
|
// Use release note from PR body if defined
|
|
const releaseNote = findReleaseNote(pr.body)
|
|
if (releaseNote !== undefined) {
|
|
return releaseNote === null
|
|
? null
|
|
: `${releaseNote} -${issueRef}${attribution}`
|
|
}
|
|
|
|
return `[${type}] ${description} -${issueRef}${attribution}`
|
|
}
|
|
|
|
export async function convertToChangelogFormat(
|
|
lines: ReadonlyArray<string>
|
|
): Promise<ReadonlyArray<string>> {
|
|
const entries = []
|
|
for (const line of lines) {
|
|
try {
|
|
const commit = parseCommitTitle(line)
|
|
const pr = await fetchPR(commit.prID)
|
|
if (!pr) {
|
|
throw new Error(`Unable to get PR from API: ${commit.prID}`)
|
|
}
|
|
// Skip release PRs
|
|
if (pr.headRefName.startsWith('releases/')) {
|
|
continue
|
|
}
|
|
|
|
const entry = getChangelogEntry(commit, pr)
|
|
if (entry !== null) {
|
|
entries.push(entry)
|
|
}
|
|
} catch (e) {
|
|
console.warn('Unable to parse line, using the full message.', e)
|
|
|
|
entries.push(`[${PlaceholderChangeType}] ${line}`)
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
export function getChangelogEntriesSince(previousVersion: string): string[] {
|
|
const root = Path.dirname(Path.dirname(__dirname))
|
|
const changelogPath = Path.join(root, 'changelog.json')
|
|
|
|
// eslint-disable-next-line no-sync
|
|
const buffer = Fs.readFileSync(changelogPath)
|
|
const changelogText = buffer.toString()
|
|
|
|
const changelogAll: { releases: any } = JSON.parse(changelogText)
|
|
|
|
const releases = changelogAll.releases
|
|
|
|
const existingChangelog = []
|
|
|
|
for (const prop of Object.getOwnPropertyNames(releases)) {
|
|
const isAfter = greaterThan(prop, previousVersion)
|
|
if (!isAfter) {
|
|
continue
|
|
}
|
|
|
|
if (prop.endsWith('-beta0')) {
|
|
// by convention we push the production updates out to beta
|
|
// to ensure both channels are up to date
|
|
continue
|
|
}
|
|
|
|
const entries: string[] = releases[prop]
|
|
if (entries != null) {
|
|
const validEntries = entries.filter(e => {
|
|
const match = ChangelogEntryRegex.exec(e)
|
|
return match != null
|
|
})
|
|
existingChangelog.push(...validEntries)
|
|
}
|
|
}
|
|
return existingChangelog
|
|
}
|