diff --git a/.gitignore b/.gitignore index 3c3629e..9209ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +out diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..a827c56 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +!out diff --git a/package.json b/package.json index e0f96de..18002ec 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,15 @@ "name": "what-the-changelog", "version": "0.0.1", "description": "", - "main": "index.js", + "main": "out/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc" }, "author": "", "license": "MIT", "devDependencies": { + "@types/node": "^8.0.20", "typescript": "^2.4.2" } } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..f1af914 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,60 @@ +import * as HTTPS from "https"; + +export interface IAPIPR { + readonly title: string; + readonly body: string; +} + +type GraphQLResponse = { + readonly data: { + readonly repository: { + readonly pullRequest: IAPIPR; + }; + }; +}; + +export function fetchPR(id: number): Promise { + return new Promise((resolve, reject) => { + const options: HTTPS.RequestOptions = { + host: "api.github.com", + protocol: "https:", + path: "/graphql", + method: "POST", + headers: { + Authorization: `bearer ${process.env.GITHUB_ACCESS_TOKEN}`, + "User-Agent": "what-the-changelog" + } + }; + + const request = HTTPS.request(options, response => { + let received = ""; + response.on("data", chunk => { + received += chunk; + }); + + response.on("end", () => { + try { + const json: GraphQLResponse = JSON.parse(received); + const pr = json.data.repository.pullRequest; + resolve(pr); + } catch (e) { + resolve(null); + } + }); + }); + + const graphql = ` +{ + repository(owner: "desktop", name: "desktop") { + pullRequest(number: ${id}) { + title + body + } + } +} +`; + request.write(JSON.stringify({ query: graphql })); + + request.end(); + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..964bb7c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +import { run } from "./run"; + +const args = process.argv.splice(2); +run(args); diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..7fea02a --- /dev/null +++ b/src/run.ts @@ -0,0 +1,102 @@ +import { spawn } from "./spawn"; +import { fetchPR, IAPIPR } from "./api"; + +async function getLogLines( + previousVersion: string +): Promise> { + const log = await spawn("git", [ + "log", + `...${previousVersion}`, + "--merges", + "--grep='Merge pull request'", + "--format=format:%s", + "-z", + "--" + ]); + + return log.split("\0"); +} + +interface IParsedCommit { + readonly id: number; + readonly remote: 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 { + id, + remote: matches[2] + }; +} + +function capitalized(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +const PlaceholderChangeType = "???"; + +function getChangelogEntry(prID: number, pr: IAPIPR): string { + let issueRef = ""; + let type = PlaceholderChangeType; + const description = capitalized(pr.title); + + const re = /Fixes #(\d+)/gi; + let match; + do { + match = re.exec(pr.body); + if (match && match.length > 1) { + issueRef += ` #${match[1]}`; + } + } while (match); + + if (issueRef.length) { + type = "Fixed"; + } else { + issueRef = ` #${prID}`; + } + + return `[${type}] ${description} -${issueRef}`; +} + +async function getChangelogEntries( + lines: ReadonlyArray +): Promise> { + const entries = []; + for (const line of lines) { + try { + const commit = parseCommitTitle(line); + const pr = await fetchPR(commit.id); + if (!pr) { + throw new Error(`Unable to get PR from API: ${commit.id}`); + } + + const entry = getChangelogEntry(commit.id, pr); + entries.push(entry); + } catch (e) { + console.warn("Unable to parse line, using the full message.", e); + + entries.push(`[${PlaceholderChangeType}] ${line}`); + } + } + + return entries; +} + +export async function run(args: ReadonlyArray): Promise { + const previousVersion = args[0]; + const lines = await getLogLines(previousVersion); + const changelogEntries = await getChangelogEntries(lines); + console.log(JSON.stringify(changelogEntries)); +} diff --git a/src/spawn.ts b/src/spawn.ts new file mode 100644 index 0000000..517ff82 --- /dev/null +++ b/src/spawn.ts @@ -0,0 +1,31 @@ +import * as ChildProcess from "child_process"; + +export function spawn( + cmd: string, + args: ReadonlyArray +): Promise { + return new Promise((resolve, reject) => { + const child = ChildProcess.spawn(cmd, args as string[], { shell: true }); + let receivedData = ""; + + child.on("error", reject); + + child.stdout.on("data", data => { + receivedData += data; + }); + + child.on("close", (code, signal) => { + if (code === 0) { + resolve(receivedData); + } else { + reject( + new Error( + `'${cmd} ${args.join( + " " + )}' exited with code ${code}, signal ${signal}` + ) + ); + } + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0222691 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "es6", + "noImplicitAny": true, + "sourceMap": false, + "strict": true, + "outDir": "./out" + }, + "exclude": ["node_modules", "out"], + "compileOnSave": false +}