Skip to content
Snippets Groups Projects
Unverified Commit beda4b0c authored by Jeff Bruemmer's avatar Jeff Bruemmer Committed by GitHub
Browse files

Add product categories to release notes (#46298)

parent d312a304
No related branches found
No related tags found
No related merge requests found
import { match } from 'ts-pattern';
import { match } from "ts-pattern";
import { nonUserFacingLabels, hiddenLabels } from "./constants";
import { getMilestoneIssues, isLatestRelease, hasBeenReleased } from "./github";
import type { ReleaseProps, Issue } from "./types";
import type { Issue, ReleaseProps } from "./types";
import {
isEnterpriseVersion,
isRCVersion,
isValidVersionString,
} from "./version-helpers";
const releaseTemplate = `## Upgrading
const releaseTemplate = `**Upgrading**
> Before you upgrade, back up your Metabase application database!
You can download a .jar of the release, or get the latest on Docker. Make sure to back up your Metabase
database before you upgrade! Need help? Check out our [upgrading instructions](https://metabase.com/docs/latest/operations-guide/upgrading-metabase.html).
Check out our [upgrading instructions](https://metabase.com/docs/latest/operations-guide/upgrading-metabase).
Docker image: {{docker-tag}}
Download the JAR here: {{download-url}}
**Notes**
## Notes
SHA-256 checksum for the {{version}} JAR:
......@@ -29,21 +29,21 @@ SHA-256 checksum for the {{version}} JAR:
<details>
<summary><h2>Changelog</h2></summary>
**Enhancements**
### Enhancements
{{enhancements}}
**Bug fixes**
### Bug fixes
{{bug-fixes}}
**Already Fixed**
### Already Fixed
Issues that we have recently confirmed to have been fixed at some point in the past.
Issues confirmed to have been fixed in a previous release.
{{already-fixed}}
**Under the Hood**
### Under the Hood
{{under-the-hood}}
......@@ -52,15 +52,15 @@ Issues that we have recently confirmed to have been fixed at some point in the p
`;
const hasLabel = (issue: Issue, label: string) => {
if (typeof issue.labels === 'string') {
if (typeof issue.labels === "string") {
return issue.labels.includes(label);
}
return issue.labels.some(tag => tag.name === label);
}
};
const isBugIssue = (issue: Issue) => {
return hasLabel(issue, "Type:Bug");
}
};
const isAlreadyFixedIssue = (issue: Issue) => {
return hasLabel(issue, ".Already Fixed");
......@@ -68,13 +68,14 @@ const isAlreadyFixedIssue = (issue: Issue) => {
const isNonUserFacingIssue = (issue: Issue) => {
return nonUserFacingLabels.some(label => hasLabel(issue, label));
}
};
const isHiddenIssue = (issue: Issue) => {
return hiddenLabels.some(label => hasLabel(issue, label));
}
};
const formatIssue = (issue: Issue) => `- ${issue.title.trim()} (#${issue.number})`;
const formatIssue = (issue: Issue) =>
`- ${issue.title.trim()} (#${issue.number})`;
export const getDockerTag = (version: string) => {
const imagePath = `${process.env.DOCKERHUB_OWNER}/${
......@@ -99,34 +100,117 @@ export const getReleaseTitle = (version: string) => {
};
enum IssueType {
bugFixes = 'bugFixes',
enhancements = 'enhancements',
alreadyFixedIssues = 'alreadyFixedIssues',
underTheHoodIssues = 'underTheHoodIssues',
bugFixes = "bugFixes",
enhancements = "enhancements",
alreadyFixedIssues = "alreadyFixedIssues",
underTheHoodIssues = "underTheHoodIssues",
}
// Product area labels take the form of "Category/Subcategory", e.g., "Querying/MBQL"
// We're only interested in the main product category, e.g., "Querying"
enum ProductCategory {
administration = "Administration",
database = "Database",
embedding = "Embedding",
operation = "Operation",
organization = "Organization",
querying = "Querying",
reporting = "Reporting",
visualization = "Visualization",
other = "Other",
}
const issueMap: Record<IssueType, Issue[]> = {
bugFixes: [],
enhancements: [],
alreadyFixedIssues: [],
underTheHoodIssues: [],
type CategoryIssueMap = Record<Partial<ProductCategory>, Issue[]>;
type IssueMap = {
[IssueType.bugFixes]: CategoryIssueMap;
[IssueType.enhancements]: CategoryIssueMap;
[IssueType.alreadyFixedIssues]: CategoryIssueMap;
[IssueType.underTheHoodIssues]: CategoryIssueMap;
};
const getIssueType = (issue: Issue): IssueType => {
return match(issue)
.when(isNonUserFacingIssue, () => IssueType.underTheHoodIssues)
.when(isAlreadyFixedIssue, () => IssueType.alreadyFixedIssues)
.when(isBugIssue, () => IssueType.bugFixes)
.otherwise(() => IssueType.enhancements);
};
const getLabels = (issue: Issue): string[] => {
if (typeof issue.labels === "string") {
return issue.labels.split(",");
}
return issue.labels.map(label => label.name || "");
};
const hasCategory = (issue: Issue, categoryName: ProductCategory): boolean => {
const labels = getLabels(issue);
return labels.some(label => label.includes(categoryName));
};
export const getProductCategory = (issue: Issue): ProductCategory => {
const category = Object.values(ProductCategory).find(categoryName =>
hasCategory(issue, categoryName)
);
return category ?? ProductCategory.other;
};
// Format issues for a single product category
const formatIssueCategory = (categoryName: ProductCategory, issues: Issue[]): string => {
return `**${categoryName}**\n\n${issues.map(formatIssue).join("\n")}`;
};
// We want to alphabetize the issues by product category, with "Other" (uncategorized) issues as the caboose
const sortCategories = (categories: ProductCategory[]) => {
const uncategorizedIssues = categories.filter(
category => category === ProductCategory.other,
);
const sortedCategories = categories
.filter(cat => cat !== ProductCategory.other)
.sort((a, b) => a.localeCompare(b));
return [
...sortedCategories,
...uncategorizedIssues,
];
};
// For each issue category ("Enhancements", "Bug Fixes", etc.), we want to group issues by product category
const formatIssues = (issueMap: CategoryIssueMap): string => {
const categories = sortCategories(
Object.keys(issueMap) as ProductCategory[],
);
return categories
.map(categoryName => formatIssueCategory(categoryName, issueMap[categoryName]))
.join("\n\n");
};
export const categorizeIssues = (issues: Issue[]) => {
return issues
.filter(issue => !isHiddenIssue(issue))
.reduce((issueMap, issue) => {
const category: IssueType = match(issue)
.when(isNonUserFacingIssue, () => IssueType.underTheHoodIssues)
.when(isAlreadyFixedIssue, () => IssueType.alreadyFixedIssues)
.when(isBugIssue, () => IssueType.bugFixes)
.otherwise(() => IssueType.enhancements);
.reduce((issueMap: IssueMap, issue: Issue) => {
const issueType = getIssueType(issue);
const productCategory = getProductCategory(issue);
return {
...issueMap,
[category]: [...issueMap[category], issue],
}
}, { ...issueMap });
[issueType]: {
...issueMap[issueType],
[productCategory]: [
...issueMap[issueType][productCategory] ?? [],
issue,
],
},
};
}, {
[IssueType.bugFixes]: {},
[IssueType.enhancements]: {},
[IssueType.alreadyFixedIssues]: {},
[IssueType.underTheHoodIssues]: {},
} as IssueMap);
};
export const generateReleaseNotes = ({
......@@ -143,11 +227,20 @@ export const generateReleaseNotes = ({
return releaseTemplate
.replace(
"{{enhancements}}",
issuesByType.enhancements.map(formatIssue).join("\n") ?? "",
formatIssues(issuesByType.enhancements),
)
.replace(
"{{bug-fixes}}",
formatIssues(issuesByType.bugFixes),
)
.replace(
"{{already-fixed}}",
formatIssues(issuesByType.alreadyFixedIssues),
)
.replace(
"{{under-the-hood}}",
formatIssues(issuesByType.underTheHoodIssues),
)
.replace("{{bug-fixes}}", issuesByType.bugFixes.map(formatIssue).join("\n") ?? "")
.replace("{{already-fixed}}", issuesByType.alreadyFixedIssues.map(formatIssue).join("\n"))
.replace("{{under-the-hood}}", issuesByType.underTheHoodIssues.map(formatIssue).join("\n"))
.replace("{{docker-tag}}", getDockerTag(version))
.replace("{{download-url}}", getDownloadUrl(version))
.replace("{{version}}", version)
......@@ -167,9 +260,11 @@ export async function publishRelease({
const issues = await getMilestoneIssues({ version, github, owner, repo });
const isLatest: 'true' | 'false' = !isEnterpriseVersion(version) && await isLatestRelease({ version, github, owner, repo })
? 'true'
: 'false';
const isLatest: "true" | "false" =
!isEnterpriseVersion(version) &&
(await isLatestRelease({ version, github, owner, repo }))
? "true"
: "false";
const payload = {
owner,
......@@ -206,8 +301,12 @@ export async function getChangelog({
github,
owner,
repo,
milestoneStatus: isAlreadyReleased ? "closed" : "open"
milestoneStatus: isAlreadyReleased ? "closed" : "open",
});
return generateReleaseNotes({ version, checksum: "checksum-placeholder", issues });
return generateReleaseNotes({
version,
checksum: "checksum-placeholder",
issues,
});
}
import { generateReleaseNotes, getReleaseTitle, categorizeIssues } from "./release-notes";
import {
generateReleaseNotes,
getReleaseTitle,
categorizeIssues,
} from "./release-notes";
import type { Issue } from "./types";
describe("Release Notes", () => {
......@@ -26,22 +30,36 @@ describe("Release Notes", () => {
{
number: 1,
title: "Bug Issue",
labels: [{ name: "Type:Bug" }],
labels: [{ name: "Type:Bug" }, { name: "Embedding/Interactive" }],
},
{
number: 2,
title: "Feature Issue",
labels: [{ name: "something" }],
labels: [{ name: "Querying/MBQL" }],
},
{
number: 3,
title: "Issue Already Fixed",
labels: [{ name: ".Already Fixed" }],
labels: [{ name: ".Already Fixed" }, { name: "Embedding/Static" }],
},
{
number: 4,
title: "Issue That Users Don't Care About",
labels: [{ name: ".CI & Tests" }],
labels: [
{ name: ".CI & Tests" },
{ name: "Administration/Permissions" },
{ name: "Embedding/Interactive" },
],
},
{
number: 5,
title: "Another feature issue",
labels: [{ name: "Reporting/Dashboards" }],
},
{
number: 6,
title: "A bug fix that lacks a category label",
labels: [{ name: "Type:Bug" }],
},
] as Issue[];
......@@ -55,10 +73,18 @@ describe("Release Notes", () => {
expect(notes).toContain("SHA-256 checksum for the v0.2.3 JAR");
expect(notes).toContain("1234567890abcdef");
expect(notes).toContain("**Enhancements**\n\n- Feature Issue (#2)");
expect(notes).toContain("**Bug fixes**\n\n- Bug Issue (#1)");
expect(notes).toContain("**Already Fixed**\n\nIssues that we have recently confirmed to have been fixed at some point in the past.\n\n- Issue Already Fixed (#3)");
expect(notes).toContain("**Under the Hood**\n\n- Issue That Users Don't Care About (#4)");
expect(notes).toContain(
"### Enhancements\n\n**Querying**\n\n- Feature Issue (#2)",
);
expect(notes).toContain(
"### Bug fixes\n\n**Embedding**\n\n- Bug Issue (#1)",
);
expect(notes).toContain(
"### Already Fixed\n\nIssues confirmed to have been fixed in a previous release.\n\n**Embedding**\n\n- Issue Already Fixed (#3)",
);
expect(notes).toContain(
"### Under the Hood\n\n**Administration**\n\n- Issue That Users Don't Care About (#4)",
);
expect(notes).toContain("metabase/metabase:v0.2.3");
expect(notes).toContain(
......@@ -76,10 +102,18 @@ describe("Release Notes", () => {
expect(notes).toContain("SHA-256 checksum for the v1.2.3 JAR");
expect(notes).toContain("1234567890abcdef");
expect(notes).toContain("**Enhancements**\n\n- Feature Issue (#2)");
expect(notes).toContain("**Bug fixes**\n\n- Bug Issue (#1)");
expect(notes).toContain("**Already Fixed**\n\nIssues that we have recently confirmed to have been fixed at some point in the past.\n\n- Issue Already Fixed (#3)");
expect(notes).toContain("**Under the Hood**\n\n- Issue That Users Don't Care About (#4)");
expect(notes).toContain(
"### Enhancements\n\n**Querying**\n\n- Feature Issue (#2)",
);
expect(notes).toContain(
"### Bug fixes\n\n**Embedding**\n\n- Bug Issue (#1)",
);
expect(notes).toContain(
"### Already Fixed\n\nIssues confirmed to have been fixed in a previous release.\n\n**Embedding**\n\n- Issue Already Fixed (#3)",
);
expect(notes).toContain(
"### Under the Hood\n\n**Administration**\n\n- Issue That Users Don't Care About (#4)",
);
expect(notes).toContain("metabase/metabase-enterprise:v1.2.3");
expect(notes).toContain(
......@@ -88,71 +122,164 @@ describe("Release Notes", () => {
});
});
describe('categorizeIssues', () => {
it('should categorize bug issues', () => {
describe("categorizeIssues", () => {
it("should categorize bug issues", () => {
const issue = {
number: 1,
title: "Bug Issue",
labels: [{ name: "Type:Bug" }],
} as Issue;
number: 1,
title: "Bug Issue",
labels: [{ name: "Type:Bug" }, { name: "Querying/MBQL" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.bugFixes).toEqual([issue]);
const issuesAndCategories = {
Querying: [
{
labels: [{ name: "Type:Bug" }, { name: "Querying/MBQL" }],
number: 1,
title: "Bug Issue",
},
],
};
expect(categorizedIssues.bugFixes).toEqual(issuesAndCategories);
});
it('should categorize already fixed issues', () => {
it("should categorize already fixed issues", () => {
const issue = {
number: 3,
title: "Already Fixed Issue",
labels: [{ name: ".Already Fixed" }],
} as Issue;
number: 3,
title: "Already Fixed Issue",
labels: [{ name: ".Already Fixed" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
const sortedIssues = {
bugFixes: {},
enhancements: {},
alreadyFixedIssues: {
Other: [
{
number: 3,
title: "Already Fixed Issue",
labels: [
{
name: ".Already Fixed",
},
],
},
],
},
underTheHoodIssues: {},
};
expect(categorizedIssues.alreadyFixedIssues).toEqual([issue]);
expect(categorizedIssues.alreadyFixedIssues).toEqual(
sortedIssues.alreadyFixedIssues,
);
});
it('should categorize non-user-facing issues', () => {
it("should categorize non-user-facing issues", () => {
const issue = {
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }],
} as Issue;
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
const sortedIssues = {
bugFixes: {},
enhancements: {},
alreadyFixedIssues: {},
underTheHoodIssues: {
Other: [
{
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }],
},
],
},
};
expect(categorizedIssues.underTheHoodIssues).toEqual([issue]);
expect(categorizedIssues.underTheHoodIssues).toEqual(
sortedIssues.underTheHoodIssues,
);
});
it('should categorize all other issues as enhancements', () => {
it("should categorize all other issues as enhancements", () => {
const issue = {
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
} as Issue;
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.enhancements).toEqual([issue]);
const sortedIssues = {
bugFixes: {},
enhancements: {
Other: [
{
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
},
],
},
alreadyFixedIssues: {},
underTheHoodIssues: {},
};
expect(categorizedIssues.enhancements).toEqual(sortedIssues.enhancements);
});
it('should prioritize non-user-facing issues above all', () => {
it("should prioritize non-user-facing issues above all", () => {
const issue = {
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }, { name: "Type:Bug" }, { name: ".Already Fixed" }, { name: "Ptitard" } ],
labels: [
{ name: ".CI & Tests" },
{ name: "Type:Bug" },
{ name: ".Already Fixed" },
{ name: "Ptitard" },
],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.underTheHoodIssues).toEqual([issue]);
expect(categorizedIssues.bugFixes).toEqual([]);
expect(categorizedIssues.alreadyFixedIssues).toEqual([]);
expect(categorizedIssues.enhancements).toEqual([]);
const sortedIssues = {
bugFixes: {},
enhancements: {},
alreadyFixedIssues: {},
underTheHoodIssues: {
Other: [
{
number: 4,
title: "Non User Facing Issue",
labels: [
{
name: ".CI & Tests",
},
{
name: "Type:Bug",
},
{
name: ".Already Fixed",
},
{
name: "Ptitard",
},
],
},
],
},
};
expect(categorizedIssues.underTheHoodIssues).toEqual(
sortedIssues.underTheHoodIssues,
);
expect(categorizedIssues.bugFixes).toEqual({});
expect(categorizedIssues.alreadyFixedIssues).toEqual({});
expect(categorizedIssues.enhancements).toEqual({});
});
it('should omit hidden issues', () => {
it("should omit hidden issues", () => {
const issue = {
number: 5,
title: "Docs Issue",
......@@ -161,28 +288,31 @@ describe("Release Notes", () => {
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.enhancements).toEqual([]);
expect(categorizedIssues.bugFixes).toEqual([]);
expect(categorizedIssues.alreadyFixedIssues).toEqual([]);
expect(categorizedIssues.underTheHoodIssues).toEqual([]);
expect(categorizedIssues.enhancements).toEqual({});
expect(categorizedIssues.bugFixes).toEqual({});
expect(categorizedIssues.alreadyFixedIssues).toEqual({});
expect(categorizedIssues.underTheHoodIssues).toEqual({});
});
it('should put issues in only one bucket', () => {
it("should put issues in only one bucket", () => {
const issues = [
{
number: 1,
title: "Bug Issue",
labels: [{ name: "Type:Bug" }],
labels: [{ name: "Type:Bug" }, { name: "Embedding/Interactive" }],
},
{
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
labels: [{ name: "something" }, { name: "Querying/MBQL" }],
},
{
number: 3,
title: "Already Fixed Issue",
labels: [{ name: ".Already Fixed" }],
labels: [
{ name: ".Already Fixed" },
{ name: "Reporting/Dashboards" },
],
},
{
number: 4,
......@@ -192,21 +322,84 @@ describe("Release Notes", () => {
{
number: 5,
title: "Non User Facing Issue 2",
labels: [{ name: ".Building & Releasing" }],
labels: [
{ name: ".Building & Releasing" },
{ name: "Visualization/Tables" },
],
},
{
number: 6,
title: "Docs Issue",
labels: [{ name: "Type:Documentation" }],
labels: [
{ name: "Type:Documentation" },
{ name: "Reporting/Dashboards" },
{ name: "Databases/PostgreSQL" },
],
},
] as Issue[];
const categorizedIssues = categorizeIssues(issues);
expect(categorizedIssues.bugFixes).toEqual([issues[0]]);
expect(categorizedIssues.enhancements).toEqual([issues[1]]);
expect(categorizedIssues.alreadyFixedIssues).toEqual([issues[2]]);
expect(categorizedIssues.underTheHoodIssues).toEqual([issues[3], issues[4]]);
const sortedIssues = {
bugFixes: {
Embedding: [
{
number: 1,
title: "Bug Issue",
labels: [{ name: "Type:Bug" }, { name: "Embedding/Interactive" }],
},
],
},
enhancements: {
Querying: [
{
number: 2,
title: "Big Feature",
labels: [{ name: "something" }, { name: "Querying/MBQL" }],
},
],
},
alreadyFixedIssues: {
Reporting: [
{
number: 3,
title: "Already Fixed Issue",
labels: [
{ name: ".Already Fixed" },
{ name: "Reporting/Dashboards" },
],
},
],
},
underTheHoodIssues: {
Other: [
{
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }],
},
],
Visualization: [
{
number: 5,
title: "Non User Facing Issue 2",
labels: [
{ name: ".Building & Releasing" },
{ name: "Visualization/Tables" },
],
},
],
},
};
expect(categorizedIssues.bugFixes).toEqual(sortedIssues.bugFixes);
expect(categorizedIssues.enhancements).toEqual(sortedIssues.enhancements);
expect(categorizedIssues.alreadyFixedIssues).toEqual(
sortedIssues.alreadyFixedIssues,
);
expect(categorizedIssues.underTheHoodIssues).toEqual(
sortedIssues.underTheHoodIssues,
);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment