"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const await_to_js_1 = tslib_1.__importDefault(require("await-to-js"));
const dedent_1 = tslib_1.__importDefault(require("dedent"));
const fs = tslib_1.__importStar(require("fs"));
const lodash_chunk_1 = tslib_1.__importDefault(require("lodash.chunk"));
const semver_1 = require("semver");
const util_1 = require("util");
const typescript_memoize_1 = require("typescript-memoize");
const changelog_1 = tslib_1.__importDefault(require("./changelog"));
const log_parse_1 = tslib_1.__importDefault(require("./log-parse"));
const semver_2 = tslib_1.__importStar(require("./semver"));
const exec_promise_1 = tslib_1.__importDefault(require("./utils/exec-promise"));
const logger_1 = require("./utils/logger");
const make_hooks_1 = require("./utils/make-hooks");
exports.defaultLabels = [
    semver_2.default.major,
    semver_2.default.minor,
    semver_2.default.patch,
    'skip-release',
    'release',
    'prerelease'
];
/** Determine if a label is a label used for versioning */
exports.isVersionLabel = (label) => exports.defaultLabels.includes(label);
exports.defaultLabelDefinition = {
    [semver_2.default.major]: [
        {
            name: 'major',
            title: '💥  Breaking Change',
            description: 'Increment the major version when merged'
        }
    ],
    [semver_2.default.minor]: [
        {
            name: 'minor',
            title: '🚀  Enhancement',
            description: 'Increment the minor version when merged'
        }
    ],
    [semver_2.default.patch]: [
        {
            name: 'patch',
            title: '🐛  Bug Fix',
            description: 'Increment the patch version when merged'
        }
    ],
    'skip-release': [
        {
            name: 'skip-release',
            description: 'Preserve the current version when merged'
        }
    ],
    release: [
        {
            name: 'release',
            description: 'Create a release when this pr is merged'
        }
    ],
    prerelease: [
        {
            name: 'prerelease',
            title: '🚧 Prerelease',
            description: 'Create a pre-release version when merged'
        }
    ],
    internal: [
        {
            name: 'internal',
            title: '🏠  Internal',
            description: 'Changes only affect the internal API'
        }
    ],
    documentation: [
        {
            name: 'documentation',
            title: '📝  Documentation',
            description: 'Changes only affect the documentation'
        }
    ]
};
/** Construct a map of label => semver label */
exports.getVersionMap = (labels = exports.defaultLabelDefinition) => Object.entries(labels).reduce((semVer, [label, labelDef]) => {
    if (exports.isVersionLabel(label) && labelDef) {
        semVer.set(label, labelDef.map(l => l.name));
    }
    return semVer;
}, new Map());
const readFile = util_1.promisify(fs.readFile);
const writeFile = util_1.promisify(fs.writeFile);
/**
 * Generate a GitHub graphql query to find all the commits related
 * to a PR.
 */
function buildSearchQuery(owner, project, commits) {
    const repo = `${owner}/${project}`;
    const query = commits.reduce((q, commit) => {
        const subQuery = `repo:${repo} ${commit.hash}`;
        return dedent_1.default `
      ${q}

      hash_${commit.hash}: search(query: "${subQuery}", type: ISSUE, first: 1) {
        edges {
          node {
            ... on PullRequest {
              number
              state
              body
              labels(first: 10) {
                edges {
                  node {
                    name
                  }
                }
              }
            }
          }
        }
      }
    `;
    }, '');
    if (!query) {
        return;
    }
    return `{
    ${query}
    rateLimit {
      limit
      cost
      remaining
      resetAt
    }
  }`;
}
exports.buildSearchQuery = buildSearchQuery;
/** Use the graphql query result to fill in more information about a commit */
function processQueryResult(key, result, commitsWithoutPR) {
    const hash = key.split('hash_')[1];
    const commit = commitsWithoutPR.find(commitWithoutPR => commitWithoutPR.hash === hash);
    if (!commit) {
        return;
    }
    if (result.edges.length > 0) {
        if (result.edges[0].node.state === 'CLOSED') {
            return;
        }
        const labels = result.edges[0].node.labels
            ? result.edges[0].node.labels.edges.map(edge => edge.node)
            : [];
        commit.pullRequest = {
            number: result.edges[0].node.number,
            body: result.edges[0].node.body
        };
        commit.labels = [...labels.map(label => label.name), ...commit.labels];
    }
    else {
        commit.labels = ['pushToBaseBranch', ...commit.labels];
    }
    commit.subject = commit.subject.split('\n')[0];
    return commit;
}
/**
 * A class for interacting with the git remote
 */
class Release {
    /** Initialize the release manager */
    constructor(git, options = {
        baseBranch: 'master',
        skipReleaseLabels: [],
        labels: exports.defaultLabelDefinition
    }, logger = logger_1.dummyLog()) {
        this.options = options;
        this.logger = logger;
        this.hooks = make_hooks_1.makeReleaseHooks();
        this.versionLabels = exports.getVersionMap(options.labels);
        this.git = git;
    }
    /** Make the class that will generate changelogs for the project */
    async makeChangelog(version) {
        const project = await this.git.getProject();
        const changelog = new changelog_1.default(this.logger, {
            owner: this.git.options.owner,
            repo: this.git.options.repo,
            baseUrl: project.html_url,
            labels: this.options.labels,
            baseBranch: this.options.baseBranch
        });
        this.hooks.onCreateChangelog.call(changelog, version);
        changelog.loadDefaultHooks();
        return changelog;
    }
    /**
     * Generate a changelog from a range of commits.
     *
     * @param from - sha or tag to start changelog from
     * @param to - sha or tag to end changelog at (defaults to HEAD)
     */
    async generateReleaseNotes(from, to = 'HEAD', version) {
        const commits = await this.getCommitsInRelease(from, to);
        const changelog = await this.makeChangelog(version);
        return changelog.generateReleaseNotes(commits);
    }
    /** Get all the commits that will be included in a release */
    async getCommitsInRelease(from, to = 'HEAD') {
        const allCommits = await this.getCommits(from, to);
        const allPrCommits = await Promise.all(allCommits
            .filter(commit => commit.pullRequest)
            .map(async (commit) => {
            const [err, commits = []] = await await_to_js_1.default(this.git.getCommitsForPR(Number(commit.pullRequest.number)));
            return err ? [] : commits;
        }));
        const allPrCommitHashes = allPrCommits
            .filter(Boolean)
            .reduce((all, pr) => [...all, ...pr.map(subCommit => subCommit.sha)], []);
        const uniqueCommits = allCommits.filter(commit => (commit.pullRequest || !allPrCommitHashes.includes(commit.hash)) &&
            !commit.subject.includes('[skip ci]'));
        const commitsWithoutPR = uniqueCommits.filter(commit => !commit.pullRequest);
        const batches = lodash_chunk_1.default(commitsWithoutPR, 10);
        const queries = await Promise.all(batches
            .map(batch => buildSearchQuery(this.git.options.owner, this.git.options.repo, batch))
            .filter((q) => Boolean(q))
            .map(q => this.git.graphql(q)));
        const data = queries.filter((q) => Boolean(q));
        if (!data.length) {
            return uniqueCommits;
        }
        const commitsInRelease = [
            ...uniqueCommits
        ];
        const logParse = await this.createLogParse();
        Promise.all(data.map(results => Object.entries(results)
            .filter((result) => Boolean(result[1]))
            .map(([key, result]) => processQueryResult(key, result, commitsWithoutPR))
            .filter((commit) => Boolean(commit))
            .map(async (commit) => {
            const index = commitsWithoutPR.findIndex(commitWithoutPR => commitWithoutPR.hash === commit.hash);
            commitsInRelease[index] = await logParse.normalizeCommit(commit);
        })));
        return commitsInRelease.filter((commit) => Boolean(commit));
    }
    /** Update a changelog with a new set of release notes */
    async updateChangelogFile(title, releaseNotes, changelogPath) {
        const date = new Date().toDateString();
        let newChangelog = '#';
        if (title) {
            newChangelog += ` ${title}`;
        }
        newChangelog += ` (${date})\n\n${releaseNotes}`;
        if (fs.existsSync(changelogPath)) {
            this.logger.verbose.info('Old changelog exists, prepending changes.');
            const oldChangelog = await readFile(changelogPath, 'utf8');
            newChangelog = `${newChangelog}\n\n---\n\n${oldChangelog}`;
        }
        await writeFile(changelogPath, newChangelog);
        this.logger.verbose.info('Wrote new changelog to filesystem.');
        await exec_promise_1.default('git', ['add', changelogPath]);
    }
    /**
     * Prepend a set of release notes to the changelog.md
     *
     * @param releaseNotes - Release notes to prepend to the changelog
     * @param lastRelease - Last release version of the code. Could be the first commit SHA
     * @param currentVersion - Current version of the code
     */
    async addToChangelog(releaseNotes, lastRelease, currentVersion) {
        this.hooks.createChangelogTitle.tapPromise('Default', async () => {
            let version;
            if (lastRelease.match(/\d+\.\d+\.\d+/)) {
                version = await this.calcNextVersion(lastRelease);
            }
            else {
                // lastRelease is a git sha. no releases have been made
                const bump = await this.getSemverBump(lastRelease);
                version = semver_1.inc(currentVersion, bump);
            }
            this.logger.verbose.info('Calculated next version to be:', version);
            if (!version) {
                return '';
            }
            return this.options.noVersionPrefix || version.startsWith('v')
                ? version
                : `v${version}`;
        });
        this.logger.verbose.info('Adding new changes to changelog.');
        const title = await this.hooks.createChangelogTitle.promise();
        await this.updateChangelogFile(title || '', releaseNotes, 'CHANGELOG.md');
    }
    /**
     * Get a range of commits. The commits will have PR numbers and labels attached
     *
     * @param from - Tag or SHA to start at
     * @param to - Tag or SHA to end at (defaults to HEAD)
     */
    async getCommits(from, to = 'HEAD') {
        this.logger.verbose.info(`Getting commits from ${from} to ${to}`);
        const gitlog = await this.git.getGitLog(from, to);
        this.logger.veryVerbose.info('Got gitlog:\n', gitlog);
        const logParse = await this.createLogParse();
        const commits = await logParse.normalizeCommits(gitlog);
        this.logger.veryVerbose.info('Added labels to commits:\n', commits);
        return commits;
    }
    /** Go through the configured labels and either add them to the project or update them */
    async addLabelsToProject(labels, options = {}) {
        const oldLabels = ((await this.git.getProjectLabels()) || []).map(l => l.toLowerCase());
        const labelsToCreate = Object.entries(labels).filter(([versionLabel, labelDef]) => {
            if (!labelDef) {
                return false;
            }
            if (versionLabel === 'release' &&
                !this.options.onlyPublishWithReleaseLabel) {
                return false;
            }
            if (versionLabel === 'skip-release' &&
                this.options.onlyPublishWithReleaseLabel) {
                return false;
            }
            return true;
        });
        if (!options.dryRun) {
            await Promise.all(labelsToCreate.map(async ([label, labelDefs]) => {
                if (!labelDefs) {
                    return;
                }
                return Promise.all(labelDefs.map(async (labelDef) => {
                    if (oldLabels.some(o => labelDef.name.toLowerCase() === o)) {
                        return this.git.updateLabel(label, labelDef);
                    }
                    return this.git.createLabel(label, labelDef);
                }));
            }));
        }
        const repoMetadata = await this.git.getProject();
        const justLabelNames = labelsToCreate.reduce((acc, [, cLabel]) => [...acc, ...(cLabel || []).map(l => l.name)], []);
        if (justLabelNames.length > 0) {
            const state = options.dryRun ? 'Would have created' : 'Created';
            this.logger.log.log(`${state} labels: ${justLabelNames.join(', ')}`);
        }
        else {
            const state = options.dryRun ? 'would have been' : 'were';
            this.logger.log.log(`No labels ${state} created, they must have already been present on your project.`);
        }
        if (options.dryRun) {
            return;
        }
        this.logger.log.log(`\nYou can see these, and more at ${repoMetadata.html_url}/labels`);
    }
    /**
     * Calculate the SEMVER bump over a range of commits using the PR labels
     *
     * @param from - Tag or SHA to start at
     * @param to - Tag or SHA to end at (defaults to HEAD)
     */
    async getSemverBump(from, to = 'HEAD') {
        const commits = await this.getCommits(from, to);
        const labels = commits.map(commit => commit.labels);
        const { onlyPublishWithReleaseLabel, skipReleaseLabels } = this.options;
        const options = { onlyPublishWithReleaseLabel, skipReleaseLabels };
        this.logger.verbose.info('Calculating SEMVER bump using:\n', {
            labels,
            versionLabels: this.versionLabels,
            options
        });
        const result = semver_2.calculateSemVerBump(labels, this.versionLabels, options);
        this.logger.verbose.success('Calculated SEMVER bump:', result);
        return result;
    }
    /** Given a tag get the next incremented version */
    async calcNextVersion(lastTag) {
        const bump = await this.getSemverBump(lastTag);
        return semver_1.inc(lastTag, bump);
    }
    /** Create the class that will parse the log for PR info */
    async createLogParse() {
        const logParse = new log_parse_1.default();
        logParse.hooks.parseCommit.tapPromise('Author Info', async (commit) => this.attachAuthor(commit));
        logParse.hooks.parseCommit.tapPromise('PR Information', async (commit) => this.addPrInfoToCommit(commit));
        logParse.hooks.parseCommit.tapPromise('PR Commits', async (commit) => {
            const prsSinceLastRelease = await this.getPRsSinceLastRelease();
            return this.getPRForRebasedCommits(commit, prsSinceLastRelease);
        });
        this.hooks.onCreateLogParse.call(logParse);
        return logParse;
    }
    /** Get a the PRs that have been merged since the last GitHub release. */
    async getPRsSinceLastRelease() {
        let lastRelease;
        try {
            lastRelease = await this.git.getLatestReleaseInfo();
        }
        catch (error) {
            const firstCommit = await this.git.getFirstCommit();
            lastRelease = {
                published_at: await this.git.getCommitDate(firstCommit)
            };
        }
        if (!lastRelease) {
            return [];
        }
        const prsSinceLastRelease = await this.git.searchRepo({
            q: `is:pr is:merged merged:>=${lastRelease.published_at}`
        });
        if (!prsSinceLastRelease || !prsSinceLastRelease.items) {
            return [];
        }
        const data = await Promise.all(prsSinceLastRelease.items.map(async (pr) => this.git.getPullRequest(Number(pr.number))));
        return data.map(item => item.data);
    }
    /**
     * Add the PR info (labels and body) to the commit
     *
     * @param commit - Commit to modify
     */
    async addPrInfoToCommit(commit) {
        const modifiedCommit = Object.assign({}, commit);
        if (!modifiedCommit.labels) {
            modifiedCommit.labels = [];
        }
        if (modifiedCommit.pullRequest) {
            const info = await this.git.getPr(modifiedCommit.pullRequest.number);
            if (!info || !info.data) {
                return modifiedCommit;
            }
            const labels = info ? info.data.labels.map(l => l.name) : [];
            modifiedCommit.labels = [
                ...new Set([...labels, ...modifiedCommit.labels])
            ];
            modifiedCommit.pullRequest.body = info.data.body;
            if (!modifiedCommit.authors.find(author => Boolean(author.username))) {
                const user = await this.git.getUserByUsername(info.data.user.login);
                if (user) {
                    modifiedCommit.authors.push(Object.assign(Object.assign({}, user), { username: user.login }));
                }
            }
        }
        return modifiedCommit;
    }
    /**
     * Commits from rebased PRs do not have messages that tie them to a PR
     * Instead we have to find all PRs since the last release and try to match
     * their merge commit SHAs.
     */
    getPRForRebasedCommits(commit, pullRequests) {
        const matchPr = pullRequests.find(pr => pr.merge_commit_sha === commit.hash);
        if (!commit.pullRequest && matchPr) {
            const labels = matchPr.labels.map(label => label.name) || [];
            commit.labels = [...new Set([...labels, ...commit.labels])];
            commit.pullRequest = {
                number: matchPr.number
            };
        }
        return commit;
    }
    /** Parse the commit for information about the author and any other author that might have helped. */
    async attachAuthor(commit) {
        const modifiedCommit = Object.assign({}, commit);
        let resolvedAuthors = [];
        // If there is a pull request we will attempt to get the authors
        // from any commit in the PR
        if (modifiedCommit.pullRequest) {
            const [prCommitsErr, prCommits] = await await_to_js_1.default(this.git.getCommitsForPR(Number(modifiedCommit.pullRequest.number)));
            if (prCommitsErr || !prCommits) {
                return commit;
            }
            resolvedAuthors = await Promise.all(prCommits.map(async (prCommit) => {
                if (!prCommit.author) {
                    return prCommit.commit.author;
                }
                return Object.assign(Object.assign({}, prCommit.author), (await this.git.getUserByUsername(prCommit.author.login)));
            }));
        }
        else if (commit.authorEmail) {
            const author = commit.authorEmail.includes('@users.noreply.github.com')
                ? await this.git.getUserByUsername(commit.authorEmail.split('@users')[0])
                : await this.git.getUserByEmail(commit.authorEmail);
            resolvedAuthors.push(Object.assign({ email: commit.authorEmail, name: commit.authorName }, author));
        }
        modifiedCommit.authors = resolvedAuthors.map(author => (Object.assign(Object.assign({}, author), (author && 'login' in author ? { username: author.login } : {}))));
        modifiedCommit.authors.forEach(author => {
            this.logger.veryVerbose.info(`Found author: ${author.username} ${author.email} ${author.name}`);
        });
        return modifiedCommit;
    }
}
tslib_1.__decorate([
    typescript_memoize_1.Memoize()
], Release.prototype, "makeChangelog", null);
tslib_1.__decorate([
    typescript_memoize_1.Memoize()
], Release.prototype, "createLogParse", null);
tslib_1.__decorate([
    typescript_memoize_1.Memoize()
], Release.prototype, "getPRsSinceLastRelease", null);
exports.default = Release;
//# sourceMappingURL=release.js.map