mirror of https://github.com/grafana/grafana.git
301 lines
9.2 KiB
JavaScript
301 lines
9.2 KiB
JavaScript
import {appendFileSync, writeFileSync} from 'fs';
|
|
import {exec as execCallback} from 'node:child_process';
|
|
import {promisify} from 'node:util';
|
|
import {findPreviousVersion, semverParse} from "./semver.js";
|
|
|
|
//
|
|
// Github Action core utils: logging (notice + debug log levels), must escape
|
|
// newlines and percent signs
|
|
//
|
|
const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
const LOG = (msg) => console.log(`::notice::${escapeData(msg)}`);
|
|
|
|
|
|
// Using `git tag -l` output find the tag (version) that goes semantically
|
|
// right before the given version. This might not work correctly with some
|
|
// pre-release versions, which is why it's possible to pass previous version
|
|
// into this action explicitly to avoid this step.
|
|
const getPreviousVersion = async (version) => {
|
|
const exec = promisify(execCallback);
|
|
const {stdout} = await exec('git for-each-ref --sort=-creatordate --format \'%(refname:short)\' refs/tags');
|
|
|
|
const parsedTags = stdout
|
|
.split('\n')
|
|
.map(semverParse)
|
|
.filter(Boolean);
|
|
|
|
const parsedVersion = semverParse(version);
|
|
const prev = findPreviousVersion(parsedTags, parsedVersion);
|
|
if (!prev) {
|
|
throw `Could not find previous git tag for ${version}`;
|
|
}
|
|
return prev[5];
|
|
};
|
|
|
|
|
|
// A helper for Github GraphQL API endpoint
|
|
const graphql = async (ghtoken, query, variables) => {
|
|
const {env} = process;
|
|
const results = await fetch('https://api.github.com/graphql', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${ghtoken}`,
|
|
},
|
|
body: JSON.stringify({query, variables}),
|
|
});
|
|
|
|
const res = await results.json();
|
|
|
|
LOG(
|
|
JSON.stringify({
|
|
status: results.status,
|
|
text: results.statusText,
|
|
})
|
|
);
|
|
|
|
return res.data;
|
|
};
|
|
|
|
// Using Github GraphQL API find the timestamp for the given tag/commit hash.
|
|
// This is required for PR listing, because Github API only takes date/time as
|
|
// a "since" parameter while listing. Currently there is no way to provide two
|
|
// "commitish" items and get a list of PRs in between them.
|
|
const getCommitishDate = async (name, owner, target) => {
|
|
const result = await graphql(
|
|
ghtoken,
|
|
`
|
|
query getCommitDate($owner: String!, $name: String!, $target: String!) {
|
|
repository(owner: $owner, name: $name) {
|
|
object(expression: $target) {
|
|
... on Commit {
|
|
committedDate
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{name, owner, target}
|
|
);
|
|
return result.repository.object.committedDate;
|
|
};
|
|
|
|
// Using Github GraphQL API get a list of PRs between the two "commitish" items.
|
|
// This resoves the "since" item's timestamp first and iterates over all PRs
|
|
// till "target" using naïve pagination.
|
|
const getHistory = async (name, owner, from, to) => {
|
|
LOG(`Fetching ${owner}/${name} PRs between ${from} and ${to}`);
|
|
const query = `
|
|
query findCommitsWithAssociatedPullRequests(
|
|
$name: String!
|
|
$owner: String!
|
|
$from: String!
|
|
$to: String!
|
|
$cursor: String
|
|
) {
|
|
repository(name: $name, owner: $owner) {
|
|
ref(qualifiedName: $from) {
|
|
compare(headRef: $to) {
|
|
commits(first: 25, after: $cursor) {
|
|
totalCount
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
nodes {
|
|
id
|
|
associatedPullRequests(first: 1) {
|
|
nodes {
|
|
title
|
|
number
|
|
labels(first: 10) {
|
|
nodes {
|
|
name
|
|
}
|
|
}
|
|
commits(first: 1) {
|
|
nodes {
|
|
commit {
|
|
author {
|
|
user {
|
|
login
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`;
|
|
|
|
let cursor;
|
|
let nodes = [];
|
|
for (; ;) {
|
|
const result = await graphql(ghtoken, query, {
|
|
name,
|
|
owner,
|
|
from,
|
|
to,
|
|
cursor,
|
|
});
|
|
LOG(`GraphQL: ${JSON.stringify(result)}`);
|
|
nodes = [...nodes, ...result.repository.ref.compare.commits.nodes];
|
|
const {hasNextPage, endCursor} = result.repository.ref.compare.commits.pageInfo;
|
|
if (!hasNextPage) {
|
|
break;
|
|
}
|
|
cursor = endCursor;
|
|
}
|
|
return nodes;
|
|
};
|
|
|
|
// The main function for this action: given two "commitish" items it gets a
|
|
// list of PRs between them and filters/groups the PRs by category (bugfix,
|
|
// feature, deprecation, breaking change and plugin fixes/enhancements).
|
|
//
|
|
// PR grouping relies on Github labels only, not on the PR contents.
|
|
const getChangeLogItems = async (name, owner, from, to) => {
|
|
// check if a node contains a certain label
|
|
const hasLabel = ({labels}, label) => labels.nodes.some(({name}) => name === label);
|
|
// get all the PRs between the two "commitish" items
|
|
const history = await getHistory(name, owner, from, to);
|
|
|
|
const items = history.flatMap((node) => {
|
|
// discard PRs without a "changelog" label
|
|
const changes = node.associatedPullRequests.nodes.filter((PR) => hasLabel(PR, 'add to changelog'));
|
|
if (changes.length === 0) {
|
|
return [];
|
|
}
|
|
const item = changes[0];
|
|
const {number, url, labels} = item;
|
|
const title = item.title.replace(/^\[[^\]]+\]:?\s*/, '');
|
|
// for changelog PRs try to find a suitable category.
|
|
// Note that we can not detect "deprecation notices" like that
|
|
// as there is no suitable label yet.
|
|
const isBug = /fix/i.test(title) || hasLabel({labels}, 'type/bug');
|
|
const isBreaking = hasLabel({labels}, 'breaking change');
|
|
const isPlugin =
|
|
hasLabel({labels}, 'area/grafana/ui') ||
|
|
hasLabel({labels}, 'area/grafana/toolkit') ||
|
|
hasLabel({labels}, 'area/grafana/runtime');
|
|
const author = item.commits.nodes[0].commit.author.user?.login;
|
|
return {
|
|
repo: name,
|
|
number,
|
|
title,
|
|
author,
|
|
isBug,
|
|
isPlugin,
|
|
isBreaking,
|
|
};
|
|
});
|
|
return items;
|
|
};
|
|
|
|
// ======================================================
|
|
// GENERATE CHANGELOG
|
|
// ======================================================
|
|
|
|
LOG(`Changelog action started`);
|
|
console.log(process.argv);
|
|
const ghtoken = process.env.GITHUB_TOKEN || process.env.INPUT_GITHUB_TOKEN;
|
|
if (!ghtoken) {
|
|
throw 'GITHUB_TOKEN is not set and "github_token" input is empty';
|
|
}
|
|
|
|
const target = process.argv[2] || process.env.INPUT_TARGET;
|
|
LOG(`Target tag/branch/commit: ${target}`);
|
|
|
|
const previous = process.argv[3] || process.env.INPUT_PREVIOUS || (await getPreviousVersion(target));
|
|
|
|
LOG(`Previous tag/commit: ${previous}`);
|
|
|
|
// Get all changelog items from Grafana OSS
|
|
const oss = await getChangeLogItems('grafana', 'grafana', previous, target);
|
|
// Get all changelog items from Grafana Enterprise
|
|
const entr = await getChangeLogItems('grafana-enterprise', 'grafana', previous, target);
|
|
|
|
LOG(`Found OSS PRs: ${oss.length}`);
|
|
LOG(`Found Enterprise PRs: ${entr.length}`);
|
|
|
|
// Sort PRs and categorise them into sections
|
|
const changelog = [...oss, ...entr]
|
|
.sort((a, b) => (a.title < b.title ? -1 : 1))
|
|
.reduce(
|
|
(changelog, item) => {
|
|
if (item.isPlugin) {
|
|
changelog.plugins.push(item);
|
|
} else if (item.isBug) {
|
|
changelog.bugfixes.push(item);
|
|
} else if (item.isBreaking) {
|
|
changelog.breaking.push(item);
|
|
} else {
|
|
changelog.features.push(item);
|
|
}
|
|
return changelog;
|
|
},
|
|
{
|
|
breaking: [],
|
|
plugins: [],
|
|
bugfixes: [],
|
|
features: [],
|
|
}
|
|
);
|
|
|
|
// Convert PR numbers to Github links
|
|
const pullRequestLink = (n) => `[#${n}](https://github.com/grafana/grafana/pull/${n})`;
|
|
// Convert Github user IDs to Github links
|
|
const userLink = (u) => `[@${u}](https://github.com/${u})`;
|
|
|
|
// Now that we have a changelog - we can render some markdown as an output
|
|
const markdown = (changelog) => {
|
|
// This convers a list of changelog items into a markdown section with a list of titles/links
|
|
const section = (title, items) =>
|
|
items.length === 0
|
|
? ''
|
|
: `### ${title}
|
|
|
|
${items
|
|
.map(
|
|
(item) =>
|
|
`- ${item.title.replace(/^([^:]*:)/gm, '**$1**')} ${
|
|
item.repo === 'grafana-enterprise'
|
|
? '(Enterprise)'
|
|
: `${pullRequestLink(item.number)}${item.author ? ', ' + userLink(item.author) : ''}`
|
|
}`
|
|
)
|
|
.join('\n')}
|
|
`;
|
|
|
|
// Render all present sections for the given changelog
|
|
return `${section('Features and enhancements', changelog.features)}
|
|
${section('Bug fixes', changelog.bugfixes)}
|
|
${section('Breaking changes', changelog.breaking)}
|
|
${section('Plugin development fixes & changes', changelog.plugins)}
|
|
`;
|
|
};
|
|
|
|
const md = markdown(changelog);
|
|
|
|
// Print changelog, mostly for debugging
|
|
LOG(`Resulting markdown: ${md}`);
|
|
|
|
// Save changelog as an output for this action
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
LOG(`Output to ${process.env.GITHUB_OUTPUT}`);
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `changelog<<EOF\n${escapeData(md)}\nEOF`);
|
|
} else {
|
|
LOG('GITHUB_OUTPUT is not set');
|
|
}
|
|
|
|
// Save changelog as an output file (if requested)
|
|
if (process.env.INPUT_OUTPUT_FILE) {
|
|
LOG(`Output to ${process.env.INPUT_OUTPUT_FILE}`);
|
|
writeFileSync(process.env.INPUT_OUTPUT_FILE, md);
|
|
}
|