Compare commits

...

6 Commits

Author SHA1 Message Date
jeff bruemmer
c23dbee0b6 comments 2024-07-30 15:39:34 -04:00
jeff bruemmer
04fa60d671 link 2024-07-30 15:37:03 -04:00
jeff bruemmer
b1f3612566 var names 2024-07-30 15:35:31 -04:00
jeff bruemmer
5c4347c952 when issues aren't present, fall back to empty string 2024-07-30 15:23:01 -04:00
jeff bruemmer
9c8acdf5ae update tests to account for product category subsections 2024-07-30 15:19:45 -04:00
jeff bruemmer
2dd9512ae8 include product categories 2024-07-30 15:19:24 -04:00
2 changed files with 412 additions and 176 deletions

View File

@@ -1,24 +1,19 @@
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 {
isEnterpriseVersion,
isRCVersion,
isValidVersionString,
} from "./version-helpers";
import type { Issue } from "./types";
import { isEnterpriseVersion } 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 +24,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 +47,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 +63,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 +95,130 @@ export const getReleaseTitle = (version: string) => {
};
enum IssueType {
bugFixes = 'bugFixes',
enhancements = 'enhancements',
alreadyFixedIssues = 'alreadyFixedIssues',
underTheHoodIssues = 'underTheHoodIssues',
bugFixes = "bugFixes",
enhancements = "enhancements",
alreadyFixedIssues = "alreadyFixedIssues",
underTheHoodIssues = "underTheHoodIssues",
}
const issueMap: Record<IssueType, Issue[]> = {
bugFixes: [],
enhancements: [],
alreadyFixedIssues: [],
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",
}
// We want to first categorize issues by type, then by product category
const issueMap: Record<IssueType, Partial<Record<ProductCategory, Issue[]>>> = {
bugFixes: {},
enhancements: {},
alreadyFixedIssues: {},
underTheHoodIssues: {},
};
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, issue) => {
const issueCategory: IssueType = match(issue)
.when(isNonUserFacingIssue, () => IssueType.underTheHoodIssues)
.when(isAlreadyFixedIssue, () => IssueType.alreadyFixedIssues)
.when(isBugIssue, () => IssueType.bugFixes)
.otherwise(() => IssueType.enhancements);
return {
...issueMap,
[category]: [...issueMap[category], issue],
}
}, { ...issueMap });
const productCategory = getProductCategory(issue);
return {
...issueMap,
[issueCategory]: {
...issueMap[issueCategory],
[productCategory]: [
...(issueMap[issueCategory][productCategory] || []),
issue,
],
},
};
},
{ ...issueMap },
);
};
const getProductCategory = (issue: Issue): ProductCategory => {
const labelName =
typeof issue.labels === "string"
? issue.labels
: issue.labels.map(label => label.name).join(" ");
return match(labelName)
.when(
label => label.includes("Administration"),
() => ProductCategory.administration,
)
.when(
label => label.includes("Database"),
() => ProductCategory.database,
)
.when(
label => label.includes("Embedding"),
() => ProductCategory.embedding,
)
.when(
label => label.includes("Operation"),
() => ProductCategory.operation,
)
.when(
label => label.includes("Organization"),
() => ProductCategory.organization,
)
.when(
label => label.includes("Querying"),
() => ProductCategory.querying,
)
.when(
label => label.includes("Reporting"),
() => ProductCategory.reporting,
)
.when(
label => label.includes("Visualization"),
() => ProductCategory.visualization,
)
.otherwise(() => ProductCategory.other);
};
// Format issues for a single product category
const formatCategoryIssues = (category: string, issues: Issue[]): string => {
return `**${category}**\n\n${issues.map(formatIssue).join("\n")}`;
};
// For each issue category ("Enhancements", "Bug Fixes", etc.), we want to group issues by product category
const groupIssuesByProductCategory = (issues: Record<string, Issue[]>) => {
const categories = sortCategories(Object.keys(issues));
return categories
.map(category => formatCategoryIssues(category, issues[category]))
.join("\n\n");
};
const sortCategories = (categories: ProductCategory[]): ProductCategory[] => {
return (
categories
// We want to alphabetize the issues by product category, with "Other" (uncategorized) issues as the caboose
.filter(cat => cat !== ProductCategory.Other)
.sort((a, b) => a.localeCompare(b))
.concat(
categories.includes(ProductCategory.Other)
? [ProductCategory.Other]
: [],
)
);
};
export const generateReleaseNotes = ({
@@ -143,71 +235,22 @@ export const generateReleaseNotes = ({
return releaseTemplate
.replace(
"{{enhancements}}",
issuesByType.enhancements.map(formatIssue).join("\n") ?? "",
groupIssuesByProductCategory(issuesByType.enhancements) ?? "",
)
.replace(
"{{bug-fixes}}",
groupIssuesByProductCategory(issuesByType.bugFixes) ?? "",
)
.replace(
"{{already-fixed}}",
groupIssuesByProductCategory(issuesByType.alreadyFixedIssues) ?? "",
)
.replace(
"{{under-the-hood}}",
groupIssuesByProductCategory(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)
.replace("{{checksum}}", checksum.split(" ")[0]);
};
export async function publishRelease({
version,
checksum,
owner,
repo,
github,
}: ReleaseProps & { checksum: string }) {
if (!isValidVersionString(version)) {
throw new Error(`Invalid version string: ${version}`);
}
const issues = await getMilestoneIssues({ version, github, owner, repo });
const isLatest: 'true' | 'false' = !isEnterpriseVersion(version) && await isLatestRelease({ version, github, owner, repo })
? 'true'
: 'false';
const payload = {
owner,
repo,
tag_name: version,
name: getReleaseTitle(version),
body: generateReleaseNotes({ version, checksum, issues }),
draft: true,
prerelease: isRCVersion(version),
make_latest: isLatest,
};
return github.rest.repos.createRelease(payload);
}
export async function getChangelog({
version,
owner,
repo,
github,
}: ReleaseProps) {
if (!isValidVersionString(version)) {
throw new Error(`Invalid version string: ${version}`);
}
const isAlreadyReleased = await hasBeenReleased({
github,
owner,
repo,
version,
});
const issues = await getMilestoneIssues({
version,
github,
owner,
repo,
milestoneStatus: isAlreadyReleased ? "closed" : "open"
});
return generateReleaseNotes({ version, checksum: "checksum-placeholder", issues });
}

View File

@@ -1,4 +1,8 @@
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]);
const issuesAndCategories = {
Querying: [
{
labels: [{ name: "Type:Bug" }, { name: "Querying/MBQL" }],
number: 1,
title: "Bug Issue",
},
],
};
expect(categorizedIssues.bugFixes).toEqual([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', () => {
const issue = {
number: 4,
title: "Non User Facing Issue",
labels: [{ name: ".CI & Tests" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.underTheHoodIssues).toEqual([issue]);
});
it('should categorize all other issues as enhancements', () => {
const issue = {
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
} as Issue;
const categorizedIssues = categorizeIssues([issue]);
expect(categorizedIssues.enhancements).toEqual([issue]);
});
it('should prioritize non-user-facing issues above all', () => {
it("should categorize non-user-facing issues", () => {
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" }],
} 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(
sortedIssues.underTheHoodIssues,
);
});
it("should categorize all other issues as enhancements", () => {
const issue = {
number: 2,
title: "Big Feature",
labels: [{ name: "something" }],
} as Issue;
const categorizedIssues = categorizeIssues([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", () => {
const issue = {
number: 4,
title: "Non User Facing Issue",
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,
);
});
});
});