Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This action for [Changesets](https://github.com/atlassian/changesets) creates a
- version - The command to update version, edit CHANGELOG, read and delete changesets. Default to `changeset version` if not provided
- commit - The commit message to use. Default to `Version Packages`
- title - The pull request title. Default to `Version Packages`
- comment - Set the comment behavior on released Pull Requests/Issues. Default to `false`

### Outputs

Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ inputs:
title:
description: The pull request title. Default to `Version Packages`
required: false
comment:
description: Set the comment behavior on released Pull Requests/Issues. Default to `false`
required: false
outputs:
published:
description: A boolean value to indicate whether a publishing is happened or not
Expand Down
24 changes: 19 additions & 5 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"devDependencies": {
"@changesets/cli": "^2.9.1",
"@changesets/write": "^0.1.3",
"@types/issue-parser": "^3.0.0",
"fixturez": "^1.1.0",
"parcel": "^1.12.3",
"prettier": "^2.0.5",
Expand Down Expand Up @@ -33,6 +34,7 @@
"babel-jest": "^24.9.0",
"fs-extra": "^8.1.0",
"husky": "^3.0.3",
"issue-parser": "^6.0.0",
"jest": "^24.9.0",
"mdast-util-to-string": "^1.0.6",
"remark-parse": "^7.0.1",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined;

const result = await runPublish({
script: publishScript,
comment: Boolean(getOptionalInput("comment")) && hasChangesets,
githubToken,
});

Expand Down
213 changes: 207 additions & 6 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,23 @@ import {
getChangedPackages,
sortTheThings,
getVersionsByDirectory,
getReleaseMessage,
} from "./utils";
import * as gitUtils from "./gitUtils";
import readChangesetState from "./readChangesetState";
import resolveFrom from "resolve-from";
import issueParser from "issue-parser";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: in the GitHub interface we have "Linked issues", I assume that this list is received by their frontend from their backend and that they don't have to prepare the PR body to get that. Would it be possible to leverage that? Or are there some limitations regarding this technique? One thing that comes to my mind - the list might include more than we want but maybe it's associated with some metadata that we could leverage to filter that list?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, I wish I had found a more appropriate solution but I think we don't have the necessary information in the API response (see https://docs.github.com/en/rest/reference/pulls#get-a-pull-request).

I tried with a Pull Request that has several related issues and they don't appear in the API response.

@Andarist Andarist Mar 24, 2021

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be an indirect way using the GraphQL API: https://github.community/t/get-all-issues-linked-to-a-pull-request/14653/6

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc also claims that it's possible to query that ingo using filters: https://docs.github.com/en/github/searching-for-information-on-github/searching-issues-and-pull-requests#search-for-linked-issues-and-pull-requests

But I couldn't make it work to get actual issue numbers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fragment IssueOrPullRequestData on ReferencedSubject {
  ... on Issue {
    number
    body
    issueState: state
  }
  ... on PullRequest {
    number
    state
  }
}

{
  resource(url: "https://github.com/graphql/graphiql/pull/1914") {
    ... on PullRequest {
      timelineItems(itemTypes: [
        CONNECTED_EVENT, 
        DISCONNECTED_EVENT, 
        CROSS_REFERENCED_EVENT, 
        REFERENCED_EVENT
			], first: 100) {
        nodes {
          __typename
          ... on ReferencedEvent {
            id
            actor {
              url
            }
            isCrossRepository
            isDirectReference
            subject {
              ...IssueOrPullRequestData
            }
          }
          ... on DisconnectedEvent {
            id
            subject {
              ...IssueOrPullRequestData
            }
          }
          ... on CrossReferencedEvent {
            resourcePath
            isCrossRepository
            willCloseTarget
            target {
              ...IssueOrPullRequestData
            }
            source {
              ...IssueOrPullRequestData
            }
          }
        }
      }
    }
  }
}

got a little closer, but not quite. using this query, I was able to get the PR referenced from a referenced issue, but not the referenced issue itself 😆 ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can now be queried with:

{
  resource(url: "https://github.com/graphql/graphiql/pull/1914") {
    ... on PullRequest {
      closingIssuesReferences(first: 99) {
        nodes {
          number
        }
      }
    }
  }
}

I will be revamping this PR this week to hopefully land it before the end of the year.


type ReleaseComment = {
issue_number: number;
htmlUrl: string;
tagName: string;
};

const createRelease = async (
octokit: ReturnType<typeof github.getOctokit>,
{ pkg, tagName }: { pkg: Package; tagName: string }
) => {
{ pkg, tagName, comment }: { pkg: Package; tagName: string; comment: boolean }
): Promise<ReleaseComment[] | undefined> => {
try {
let changelogFileName = path.join(pkg.dir, "CHANGELOG.md");

Expand All @@ -33,13 +41,20 @@ const createRelease = async (
);
}

await octokit.repos.createRelease({
name: tagName,
const {
data: { html_url: htmlUrl },
} = await octokit.repos.createRelease({
tag_name: tagName,
body: changelogEntry.content,
prerelease: pkg.packageJson.version.includes("-"),
...github.context.repo,
});
if (comment) {
return await createReleaseComments(octokit, {
tagName,
htmlUrl,
});
}
} catch (err) {
// if we can't find a changelog, the user has probably disabled changelogs
if (err.code !== "ENOENT") {
Expand All @@ -48,10 +63,149 @@ const createRelease = async (
}
};

const getSearchQueries = (base: string, commits: string[]) => {
return commits.reduce((searches, commit) => {
const lastSearch = searches[searches.length - 1];

if (lastSearch && lastSearch.length + commit.length <= 256 - 6) {
searches[searches.length - 1] = `${lastSearch}+hash:${commit}`;
} else {
searches.push(`${base}+hash:${commit}`);
}

return searches;
}, [] as string[]);
};

/* Comment on released Pull Requests/Issues */
const createReleaseComments = async (
octokit: ReturnType<typeof github.getOctokit>,
{ tagName, htmlUrl }: { tagName: string; htmlUrl: string }
) => {
/*
Here are the following steps to retrieve the released PRs and issues.

1. Retrieve the tag associated with the release
2. Take the commit sha associated with the tag
3. Retrieve all the commits starting from the tag commit sha
4. Retrieve the PRs with commits sha matching the release commits
5. Map through the list of commits and the list of PRs to
find commit message or PRs body that closes an issue and
get the issue number.
6. Create a comment for each issue and PR
*/

const repo = github.context.repo;

let tagPage = 0;
let tagFound = false;
let tagCommitSha = "";

/* 1 */
while (!tagFound) {
await octokit.repos
.listTags({
...repo,
per_page: 100,
page: tagPage,
})
.then(({ data }) => {
const tag = data.find((el) => el.name === tagName);
if (tag) {
tagFound = true;
/* 2 */
tagCommitSha = tag.commit.sha;
}
tagPage += 1;
})
.catch((err) => console.warn(err));
}

/* 3 */
const commits = await octokit.repos
.listCommits({
...repo,
sha: tagCommitSha,
})
.then(({ data }) => data);

const shas = commits.map(({ sha }) => sha);

/* Build a seach query to retrieve pulls with commit hashes.
* example: repo:<OWNER>/<REPO>+type:pr+is:merged+hash:<FIRST_COMMIT_HASH>+hash:<SECOND_COMMIT_HASH>...
*/
const searchQueries = getSearchQueries(
`repo:${repo.owner}/${repo.repo}+type:pr+is:merged`,
shas
).map(
async (q) => (await octokit.search.issuesAndPullRequests({ q })).data.items
);

const queries = await (await Promise.all(searchQueries)).flat();

const queriesSet = queries.map((el) => el.number);

const filteredQueries = queries.filter(
(el, i) => queriesSet.indexOf(el.number) === i
);

/* 4 */
const pulls = await filteredQueries.filter(
async ({ number }) =>
(
await octokit.pulls.listCommits({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
})
).data.find(({ sha }) => shas.includes(sha)) ||
shas.includes(
(
await octokit.pulls.get({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
})
).data.merge_commit_sha
)
);

const parser = issueParser("github");

/* 5 */
const issues = [
...pulls.map((pr) => pr.body),
...commits.map(({ commit }) => commit.message),
].reduce((issues, message) => {
return message
? issues.concat(
parser(message)
.actions.close.filter(
(action) =>
action.slug === null ||
action.slug === undefined ||
action.slug === `${repo.owner}/${repo.repo}`
)
.map((action) => ({ number: Number.parseInt(action.issue, 10) }))
)
: issues;
}, [] as { number: number }[]);

/* 6 */
return [...new Set([...pulls, ...issues].map(({ number }) => number))].map(
(number) => ({
issue_number: number,
htmlUrl,
tagName,
})
);
};

type PublishOptions = {
script: string;
githubToken: string;
cwd?: string;
comment: boolean;
};

type PublishedPackage = { name: string; version: string };
Expand All @@ -68,10 +222,16 @@ type PublishResult =
export async function runPublish({
script,
githubToken,
comment,
cwd = process.cwd(),
}: PublishOptions): Promise<PublishResult> {
let octokit = github.getOctokit(githubToken);
let [publishCommand, ...publishArgs] = script.split(/\s+/);
let { changesets } = await readChangesetState(cwd);

const changesetsSummaries = changesets.map(({ summary }) => summary);

const { owner, repo } = github.context.repo;

let changesetPublishOutput = await execWithOutput(
publishCommand,
Expand Down Expand Up @@ -104,14 +264,40 @@ export async function runPublish({
releasedPackages.push(pkg);
}

await Promise.all(
const issueComments = await Promise.all(
releasedPackages.map((pkg) =>
createRelease(octokit, {
pkg,
tagName: `${pkg.packageJson.name}@${pkg.packageJson.version}`,
comment,
})
)
).then((releasesComments) =>
releasesComments.reduce((acc, releaseComments) => {
if (releaseComments) {
for (const releaseComment of releaseComments) {
acc[releaseComment.issue_number] = [
...acc[releaseComment.issue_number],
releaseComment,
];
}
}
return acc;
}, {} as { [key: number]: ReleaseComment[] })
);
for (const comments of Object.values(issueComments)) {
const tagNames = comments.map((comment) => comment.tagName);
const htmlUrls = comments.map((comment) => comment.htmlUrl);

const { issue_number } = comments[0];

octokit.issues.createComment({
owner,
repo,
issue_number,
body: getReleaseMessage(htmlUrls, tagNames, changesetsSummaries),
});
}
} else {
if (packages.length === 0) {
throw new Error(
Expand All @@ -127,10 +313,25 @@ export async function runPublish({

if (match) {
releasedPackages.push(pkg);
await createRelease(octokit, {
const comments = await createRelease(octokit, {
pkg,
tagName: `v${pkg.packageJson.version}`,
comment,
});
if (comments) {
for (const comment of comments) {
octokit.issues.createComment({
owner,
repo,
issue_number: comment.issue_number,
body: getReleaseMessage(
[comment.htmlUrl],
[comment.tagName],
changesetsSummaries
),
});
}
}
break;
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ export async function getVersionsByDirectory(cwd: string) {
return new Map(packages.map((x) => [x.dir, x.packageJson.version]));
}

export const getReleaseMessage = (
htmlUrls: string[],
tagNames: string[],
changesetsSummaries: string[]
) => `### 🦋 This work has been released in release versions: ${tagNames.join(
", "
)}

Release links:
${htmlUrls.map((htmlUrl) => `- ${htmlUrl}</br>`)}

Changesets summary:
${changesetsSummaries.map((changesetSummary) => `- ${changesetSummary}</br>`)}
`;

export async function getChangedPackages(
cwd: string,
previousVersions: Map<string, string>
Expand Down
Loading