Repository: atlassian/changesets
Pull request #535 (2021-03-08 02:49:22)

Allow replacing the PR/commit/author shown in the changlog line by writing `pr: some-pr-number` and etc.
An idea I had to solve the "oh no, I just merged a PR without a changeset and now that I add one, it'll attribute it to the wrong PR" problem.

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: b6bb6751e1ee9b52b41f14b549cd7f64f485a0d0

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name                         | Type  |
| ---------------------------- | ----- |
| @changesets/changelog-github | Minor |
| @changesets/get-github-info  | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/atlassian/changesets/new/allow-providing-custom-info-in-changelog-github?filename=.changeset/small-turkeys-provide.md&value=---%0A%22%40changesets%2Fchangelog-git%22%3A%20patch%0A%22%40changesets%2Fchangelog-github%22%3A%20patch%0A%22%40changesets%2Fget-github-info%22%3A%20patch%0A---%0A%0AAllow%20replacing%20the%20PR%2Fcommit%2Fauthor%20shown%20in%20the%20changlog%20line%20by%20writing%20%60pr%3A%20some-pr-number%60%20and%20etc.%0A)



Patch:
diff --git a/.changeset/olive-hotels-hear.md b/.changeset/olive-hotels-hear.md
new file mode 100644
index 0000000..c0673b6
--- /dev/null
+++ b/.changeset/olive-hotels-hear.md
@@ -0,0 +1,5 @@
+---
+"@changesets/changelog-github": minor
+---
+
+Allow replacing the PR/commit/author shown in the changlog line by writing `pr: some-pr-number` and similarly for `commit` and `author` in the changlog summary(not the frontmatter).
diff --git a/.changeset/tidy-tools-applaud.md b/.changeset/tidy-tools-applaud.md
new file mode 100644
index 0000000..34d84de
--- /dev/null
+++ b/.changeset/tidy-tools-applaud.md
@@ -0,0 +1,5 @@
+---
+"@changesets/get-github-info": minor
+---
+
+Added `getInfoFromPullRequest`
diff --git a/packages/changelog-github/package.json b/packages/changelog-github/package.json
index e628941..681011f 100644
--- a/packages/changelog-github/package.json
+++ b/packages/changelog-github/package.json
@@ -10,5 +10,8 @@
     "@changesets/get-github-info": "^0.4.5",
     "@changesets/types": "^3.0.0",
     "dotenv": "^8.1.0"
+  },
+  "devDependencies": {
+    "@changesets/parse": "*"
   }
 }
diff --git a/packages/changelog-github/src/index.ts b/packages/changelog-github/src/index.ts
index 5810ed7..806c990 100644
--- a/packages/changelog-github/src/index.ts
+++ b/packages/changelog-github/src/index.ts
@@ -1,7 +1,7 @@
 import { ChangelogFunctions } from "@changesets/types";
 // @ts-ignore
 import { config } from "dotenv";
-import { getInfo } from "@changesets/get-github-info";
+import { getInfo, getInfoFromPullRequest } from "@changesets/get-github-info";
 
 config();
 
@@ -46,23 +46,73 @@ const changelogFunctions: ChangelogFunctions = {
         'Please provide a repo to this changelog generator like this:\n"changelog": ["@changesets/changelog-github", { "repo": "org/repo" }]'
       );
     }
-    const [firstLine, ...futureLines] = changeset.summary
+
+    let prFromSummary: number | undefined;
+    let commitFromSummary: string | undefined;
+    let userFromSummary: string | undefined;
+
+    const replacedChangelog = changeset.summary
+      .replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => {
+        let num = Number(pr);
+        if (!isNaN(num)) prFromSummary = num;
+        return "";
+      })
+      .replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => {
+        commitFromSummary = commit;
+        return "";
+      })
+      .replace(/^\s*(?:author|user):\s*@?([^\s]+)/im, (_, user) => {
+        userFromSummary = user;
+        return "";
+      })
+      .trim();
+
+    const [firstLine, ...futureLines] = replacedChangelog
       .split("\n")
       .map(l => l.trimRight());
 
-    if (changeset.commit) {
-      let { links } = await getInfo({
-        repo: options.repo,
-        commit: changeset.commit
-      });
-      return `\n\n- ${links.commit}${
-        links.pull === null ? "" : ` ${links.pull}`
-      }${
-        links.user === null ? "" : ` Thanks ${links.user}!`
-      } - ${firstLine}\n${futureLines.map(l => `  ${l}`).join("\n")}`;
-    } else {
-      return `\n\n- ${firstLine}\n${futureLines.map(l => `  ${l}`).join("\n")}`;
-    }
+    const links = await (async () => {
+      if (prFromSummary !== undefined) {
+        let { links } = await getInfoFromPullRequest({
+          repo: options.repo,
+          pull: prFromSummary
+        });
+        if (commitFromSummary) {
+          links = {
+            ...links,
+            commit: `[\`${commitFromSummary}\`](https://github.com/${options.repo}/commit/${commitFromSummary})`
+          };
+        }
+        return links;
+      }
+      const commitToFetchFrom = commitFromSummary || changeset.commit;
+      if (commitToFetchFrom) {
+        let { links } = await getInfo({
+          repo: options.repo,
+          commit: commitToFetchFrom
+        });
+        return links;
+      }
+      return {
+        commit: null,
+        pull: null,
+        user: null
+      };
+    })();
+
+    const user = userFromSummary
+      ? `[@${userFromSummary}](https://github.com/${userFromSummary})`
+      : links.user;
+
+    const prefix = [
+      links.pull === null ? "" : ` ${links.pull}`,
+      links.commit === null ? "" : ` ${links.commit}`,
+      user === null ? "" : ` Thanks ${user}!`
+    ].join("");
+
+    return `\n\n-${prefix ? `${prefix} -` : ""} ${firstLine}\n${futureLines
+      .map(l => `  ${l}`)
+      .join("\n")}`;
   }
 };
 
diff --git a/packages/get-github-info/src/index.ts b/packages/get-github-info/src/index.ts
index ce8a008..0e919e4 100644
--- a/packages/get-github-info/src/index.ts
+++ b/packages/get-github-info/src/index.ts
@@ -4,12 +4,16 @@ import DataLoader from "dataloader";
 
 const validRepoNameRegex = /^[\w.-]+\/[\w.-]+$/;
 
-type RequestData = {
-  commit: string;
-  repo: string;
-};
+type RequestData =
+  | { kind: "commit"; repo: string; commit: string }
+  | { kind: "pull"; repo: string; pull: number };
+
+type ReposWithCommitsAndPRsToFetch = Record<
+  string,
+  ({ kind: "commit"; commit: string } | { kind: "pull"; pull: number })[]
+>;
 
-function makeQuery(repos: any) {
+function makeQuery(repos: ReposWithCommitsAndPRsToFetch) {
   return `
       query {
         ${Object.keys(repos)
@@ -20,12 +24,11 @@ function makeQuery(repos: any) {
             name: ${JSON.stringify(repo.split("/")[1])}
           ) {
             ${repos[repo]
-              .map(
-                (
-                  commit: string
-                ) => `a${commit}: object(expression: ${JSON.stringify(
-                  commit
-                )}) {
+              .map(data =>
+                data.kind === "commit"
+                  ? `a${data.commit}: object(expression: ${JSON.stringify(
+                      data.commit
+                    )}) {
             ... on Commit {
             commitUrl
             associatedPullRequests(first: 50) {
@@ -46,6 +49,17 @@ function makeQuery(repos: any) {
               }
             }
           }}`
+                  : `pr__${data.pull}: pullRequest(number: ${data.pull}) {
+                    url
+                    author {
+                      login
+                      url
+                    }
+                    mergeCommit {
+                      commitUrl
+                      abbreviatedOid
+                    }            
+                  }`
               )
               .join("\n")}
           }`
@@ -70,12 +84,12 @@ const GHDataLoader = new DataLoader(async (requests: RequestData[]) => {
       "Please create a GitHub personal access token at https://github.com/settings/tokens/new and add it as the GITHUB_TOKEN environment variable"
     );
   }
-  let repos: Record<RequestData["repo"], Array<RequestData["commit"]>> = {};
-  requests.forEach(({ commit, repo }) => {
+  let repos: ReposWithCommitsAndPRsToFetch = {};
+  requests.forEach(({ repo, ...data }) => {
     if (repos[repo] === undefined) {
       repos[repo] = [];
     }
-    repos[repo].push(commit);
+    repos[repo].push(data);
   });
 
   const data = await fetch("https://api.github.com/graphql", {
@@ -95,22 +109,39 @@ const GHDataLoader = new DataLoader(async (requests: RequestData[]) => {
     );
   }
 
-  let cleanedData: Record<any, any> = {};
-  let dataKeys = Object.keys(data.data);
+  let cleanedData: Record<
+    string,
+    { commit: Record<string, any>; pull: Record<string, any> }
+  > = {};
   Object.keys(repos).forEach((repo, index) => {
-    cleanedData[repo] = {};
-    for (let nearlyCommit in data.data[dataKeys[index]]) {
-      cleanedData[repo][nearlyCommit.substring(1)] =
-        data.data[dataKeys[index]][nearlyCommit];
-    }
+    let output: { commit: Record<string, any>; pull: Record<string, any> } = {
+      commit: {},
+      pull: {}
+    };
+    cleanedData[repo] = output;
+    Object.entries(data.data[`a${index}`]).forEach(([field, value]) => {
+      // this is "a" because that's how it was when it was first written, "a" means it's a commit not a pr
+      // we could change it to commit__ but then we have to get new GraphQL results from the GH API to put in the tests
+      if (field[0] === "a") {
+        output.commit[field.substring(1)] = value;
+      } else {
+        output.pull[field.replace("pr__", "")] = value;
+      }
+    });
   });
 
-  return requests.map(({ repo, commit }) => cleanedData[repo][commit]);
+  return requests.map(
+    ({ repo, ...data }) =>
+      cleanedData[repo][data.kind][
+        data.kind === "pull" ? data.pull : data.commit
+      ]
+  );
 });
 
-export async function getInfo(
-  request: RequestData
-): Promise<{
+export async function getInfo(request: {
+  commit: string;
+  repo: string;
+}): Promise<{
   user: string | null;
   pull: number | null;
   links: {
@@ -135,7 +166,7 @@ export async function getInfo(
     );
   }
 
-  const data = await GHDataLoader.load(request);
+  const data = await GHDataLoader.load({ kind: "commit", ...request });
   let user = null;
   if (data.author && data.author.user) {
     user = data.author.user;
@@ -175,3 +206,49 @@ export async function getInfo(
     }
   };
 }
+
+export async function getInfoFromPullRequest(request: {
+  pull: number;
+  repo: string;
+}): Promise<{
+  user: string | null;
+  commit: string | null;
+  links: {
+    commit: string | null;
+    pull: string;
+    user: string | null;
+  };
+}> {
+  if (request.pull === undefined) {
+    throw new Error("Please pass a pull request number");
+  }
+
+  if (!request.repo) {
+    throw new Error(
+      "Please pass a GitHub repository in the form of userOrOrg/repoName to getInfo"
+    );
+  }
+
+  if (!validRepoNameRegex.test(request.repo)) {
+    throw new Error(
+      `Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the "${validRepoNameRegex.source}" pattern)`
+    );
+  }
+
+  const data = await GHDataLoader.load({ kind: "pull", ...request });
+  let user = data?.author;
+
+  let commit = data?.mergeCommit;
+
+  return {
+    user: user ? user.login : null,
+    commit: commit ? commit.abbreviatedOid : null,
+    links: {
+      commit: commit
+        ? `[\`${commit.abbreviatedOid}\`](${commit.commitUrl})`
+        : null,
+      pull: `[#${request.pull}](https://github.com/${request.repo}/pull/${request.pull})`,
+      user: user ? `[@${user.login}](${user.url})` : null
+    }
+  };
+}


Test patch:
diff --git a/packages/changelog-github/src/index.test.ts b/packages/changelog-github/src/index.test.ts
new file mode 100644
index 0000000..f934b0f
--- /dev/null
+++ b/packages/changelog-github/src/index.test.ts
@@ -0,0 +1,119 @@
+import changelogFunctions from "./index";
+import parse from "@changesets/parse";
+
+const getReleaseLine = changelogFunctions.getReleaseLine;
+
+jest.mock(
+  "@changesets/get-github-info",
+  (): typeof import("@changesets/get-github-info") => {
+    // this is duplicated because jest.mock reordering things
+    const data = {
+      commit: "a085003",
+      user: "Andarist",
+      pull: 1613,
+      repo: "emotion-js/emotion"
+    };
+    const links = {
+      user: `[@${data.user}](https://github.com/${data.user})`,
+      pull: `[#${data.pull}](https://github.com/${data.repo}/pull/${data.pull})`,
+      commit: `[\`${data.commit}\`](https://github.com/${data.repo}/commit/${data.commit})`
+    };
+    return {
+      async getInfo({ commit, repo }) {
+        expect(commit).toBe(data.commit);
+        expect(repo).toBe(data.repo);
+        return {
+          pull: data.pull,
+          user: data.user,
+          links
+        };
+      },
+      async getInfoFromPullRequest({ pull, repo }) {
+        expect(pull).toBe(data.pull);
+        expect(repo).toBe(data.repo);
+        return {
+          commit: data.commit,
+          user: data.user,
+          links
+        };
+      }
+    };
+  }
+);
+
+const getChangeset = (content: string, commit: string | undefined) => {
+  return [
+    {
+      ...parse(
+        `---
+  pkg: "minor"
+  ---
+  
+  something
+  ${content}
+  `
+      ),
+      id: "some-id",
+      commit
+    },
+    "minor",
+    { repo: data.repo }
+  ] as const;
+};
+
+const data = {
+  commit: "a085003",
+  user: "Andarist",
+  pull: 1613,
+  repo: "emotion-js/emotion"
+};
+
+describe.each([data.commit, "wrongcommit", undefined])(
+  "with commit from changeset of %s",
+  commitFromChangeset => {
+    describe.each(["pr", "pull request", "pull"])(
+      "override pr with %s keyword",
+      keyword => {
+        test.each(["with #", "without #"] as const)("%s", async kind => {
+          expect(
+            await getReleaseLine(
+              ...getChangeset(
+                `${keyword}: ${kind === "with #" ? "#" : ""}${data.pull}`,
+                commitFromChangeset
+              )
+            )
+          ).toEqual(
+            `\n\n- [#1613](https://github.com/emotion-js/emotion/pull/1613) [\`a085003\`](https://github.com/emotion-js/emotion/commit/a085003) Thanks [@Andarist](https://github.com/Andarist)! - something\n`
+          );
+        });
+      }
+    );
+    test("override commit with commit keyword", async () => {
+      expect(
+        await getReleaseLine(
+          ...getChangeset(`commit: ${data.commit}`, commitFromChangeset)
+        )
+      ).toEqual(
+        `\n\n- [#1613](https://github.com/emotion-js/emotion/pull/1613) [\`a085003\`](https://github.com/emotion-js/emotion/commit/a085003) Thanks [@Andarist](https://github.com/Andarist)! - something\n`
+      );
+    });
+  }
+);
+
+describe.each(["author", "user"])(
+  "override author with %s keyword",
+  keyword => {
+    test.each(["with @", "without @"] as const)("%s", async kind => {
+      expect(
+        await getReleaseLine(
+          ...getChangeset(
+            `${keyword}: ${kind === "with @" ? "@" : ""}other`,
+            data.commit
+          )
+        )
+      ).toEqual(
+        `\n\n- [#1613](https://github.com/emotion-js/emotion/pull/1613) [\`a085003\`](https://github.com/emotion-js/emotion/commit/a085003) Thanks [@other](https://github.com/other)! - something\n`
+      );
+    });
+  }
+);
diff --git a/packages/get-github-info/src/index.test.ts b/packages/get-github-info/src/index.test.ts
index 3238b38..f6a52e1 100644
--- a/packages/get-github-info/src/index.test.ts
+++ b/packages/get-github-info/src/index.test.ts
@@ -1,4 +1,4 @@
-import { getInfo } from ".";
+import { getInfo, getInfoFromPullRequest } from ".";
 import nock from "nock";
 import prettier from "prettier";
 
@@ -331,3 +331,69 @@ test("throws error on invalid repo name", () => {
     `"Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the \\"^[\\\\w.-]+\\\\/[\\\\w.-]+$\\" pattern)"`
   );
 });
+
+test("associated with multiple PRs with only one merged", async () => {
+  nock("https://api.github.com", {
+    reqheaders: {
+      Authorization: `Token ${process.env.GITHUB_TOKEN}`
+    }
+  })
+    .post(apiPath, ({ query }) => {
+      expect(prettier.format(query, { parser: "graphql" }))
+        .toMatchInlineSnapshot(`
+        "query {
+          a0: repository(owner: \\"emotion-js\\", name: \\"emotion\\") {
+            pr__1613: pullRequest(number: 1613) {
+              url
+              author {
+                login
+                url
+              }
+              mergeCommit {
+                commitUrl
+                abbreviatedOid
+              }
+            }
+          }
+        }
+        "
+      `);
+      return true;
+    })
+    .reply(
+      200,
+      JSON.stringify({
+        data: {
+          a0: {
+            pr__1613: {
+              url: "https://github.com/emotion-js/emotion/pull/1613",
+              author: {
+                login: "Andarist",
+                url: "https://github.com/Andarist"
+              },
+              mergeCommit: {
+                commitUrl:
+                  "https://github.com/emotion-js/emotion/commit/a085003d4c8ca284c116668d7217fb747802ed85",
+                abbreviatedOid: "a085003"
+              }
+            }
+          }
+        }
+      })
+    );
+  let result = await getInfoFromPullRequest({
+    pull: 1613,
+    repo: "emotion-js/emotion"
+  });
+  expect(result).toMatchInlineSnapshot(`
+    Object {
+      "commit": "a085003",
+      "links": Object {
+        "commit": "[\`a085003\`](https://github.com/emotion-js/emotion/commit/a085003d4c8ca284c116668d7217fb747802ed85)",
+        "pull": "[#1613](https://github.com/emotion-js/emotion/pull/1613)",
+        "user": "[@Andarist](https://github.com/Andarist)",
+      },
+      "user": "Andarist",
+    }
+  `);
+});