@shopware-ag/gh-project-automation
Version:
Project automation for shopware
1,446 lines (1,432 loc) • 54 kB
JavaScript
import { WebClient, ErrorCode } from '@slack/web-api';
import { getOctokit } from '@actions/github';
async function closeIssue(toolkit, issueId, reason = "NOT_PLANNED") {
const res = await toolkit.github.graphql(
/* GraphQL */
`
mutation closeIssue($issueId: ID!, $reason: IssueClosedStateReason!) {
closeIssue(input: {
issueId: $issueId,
stateReason: $reason,
}) {
clientMutationId
}
}
`,
{
issueId,
reason
}
);
toolkit.core.debug(`closeIssue response: ${JSON.stringify(res)}`);
}
async function closePullRequest(toolkit, pullRequestId) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
mutation closeIssue($pullRequestId: ID!) {
closePullRequest(input: {
pullRequestId: $pullRequestId
}) {
clientMutationId
}
}
`,
{
pullRequestId
}
);
toolkit.core.debug(`closePullRequest response: ${JSON.stringify(res)}`);
}
async function getLabelByName(toolkit, repository, labelName) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getLabelId($repository: String!, $labelName: String!) {
repository(owner: "shopware", name: $repository) {
label(name: $labelName) {
id
name
url
description
color
}
}
}
`,
{
repository,
labelName
}
);
return res.repository.label;
}
async function addLabelToLabelable(toolkit, labelId, labelableId) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
mutation addLabels($labelId: ID!, $labelableId: ID!) {
addLabelsToLabelable(input: {
labelIds: [$labelId],
labelableId: $labelableId
}) {
clientMutationId
}
}
`,
{
labelId,
labelableId
}
);
toolkit.core.debug(`addLabelToIssue response: ${JSON.stringify(res)}`);
}
async function findIssueWithProjectItems(toolkit, number) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query findIssueWithProjectItems($number: Int!) {
repository(owner: "shopware", name: "shopware") {
issue(number: $number) {
projectItems(first: 20) {
nodes {
id
project {
number
}
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
}
}
id
number
}
}
}
`,
{
number
}
);
toolkit.core.debug(`findIssueInProject response: ${JSON.stringify(res)}`);
return {
node_id: res.repository.issue.id,
number: res.repository.issue.number,
projectItems: res.repository.issue.projectItems.nodes
};
}
async function findPRWithProjectItems(toolkit, number) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query findPRWithProjectItems($number: Int!) {
repository(owner: "shopware", name: "shopware") {
pullRequest(number: $number) {
projectItems(first: 20) {
nodes {
project {
number
}
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
}
}
id
number
}
}
}
`,
{
number
}
);
toolkit.core.debug(`findIssueInProject response: ${JSON.stringify(res)}`);
return {
node_id: res.repository.pullRequest.id,
number: res.repository.pullRequest.number,
projectItems: res.repository.pullRequest.projectItems.nodes
};
}
async function setFieldValue(toolkit, data) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
mutation setFieldValue($projectId: ID!, $itemId: ID!, $fieldId: ID!, $valueId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId,
itemId: $itemId,
fieldId: $fieldId,
value: {singleSelectOptionId: $valueId}
}) {
projectV2Item {
id
}
}
}
`,
data
);
toolkit.core.debug(`setFieldValue response: ${JSON.stringify(res)}`);
return res;
}
async function getProjectInfo(toolkit, data) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getProjectInfo($organization: String!, $projectNumber: Int!) {
organization(login: $organization) {
projectV2(number: $projectNumber) {
id
title
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
`,
{
organization: data.organization ?? "shopware",
projectNumber: data.number
}
);
toolkit.core.debug(`getProjectInfo response: ${JSON.stringify(res)}`);
const project = res.organization.projectV2;
return {
node_id: project.id,
title: project.title,
fields: project.fields.nodes
};
}
async function addProjectItem(toolkit, data) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
mutation addProjectItem($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {
projectId: $projectId,
contentId: $contentId
}) {
item {
id
}
}
}
`,
{
projectId: data.projectId,
contentId: data.issueId
}
);
toolkit.core.debug(`addCard response: ${JSON.stringify(res)}`);
return {
node_id: res.addProjectV2ItemById.item.id
};
}
async function getProjectIdByNumber(toolkit, number, organization = "shopware") {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getProjectIdByNumber($organization: String!, $number: Int!) {
organization(login: $organization) {
projectV2(number: $number) {
id
}
}
}
`,
{
organization,
number
}
);
return res.organization.projectV2.id;
}
async function getIssuesByProject(toolkit, projectId, cursor = null, carry = null) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getIssuesByProject($projectId: ID!, $count: Int, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: $count, after: $cursor) {
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
content {
... on Issue {
id
title
number
url
issueType {
name
}
}
}
}
}
}
}
}
`,
{
projectId,
count: 100,
cursor
}
);
const issues = res.node.items.nodes.map((item) => {
return {
id: item.content.id,
title: item.content.title,
number: item.content.number,
url: item.content.url,
status: item.fieldValueByName?.name,
labels: [],
type: item.content?.issueType?.name
};
});
if (res.node.items.pageInfo.hasNextPage) {
return await getIssuesByProject(toolkit, projectId, res.node.items.pageInfo.endCursor, [...issues, ...carry ?? []]);
} else {
return [...issues, ...carry ?? []];
}
}
async function getCommentsForIssue(toolkit, issueId, cursor = null, carry = null) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getCommentsForIssue($issueId: ID!, $count: Int, $cursor: String) {
node(id: $issueId) {
... on Issue {
comments(first: $count, after: $cursor) {
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
nodes {
id
author {
login
}
body
}
}
}
}
}
`,
{
issueId,
count: 100,
cursor
}
);
const comments = res.node.comments.nodes;
if (res.node.comments.pageInfo.hasNextPage) {
return await getCommentsForIssue(toolkit, issueId, res.node.comments.pageInfo.endCursor, [...comments, ...carry ?? []]);
} else {
return [...comments, ...carry ?? []];
}
}
async function getPullRequests(toolkit, searchQuery) {
const pullRequests = await toolkit.github.graphql(
/* GraphQL */
`
query findPullRequests($searchQuery: String!) {
search(query: $searchQuery, type: ISSUE, first: 50) {
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
nodes {
... on PullRequest {
id
title
number
url
repository {
owner {
login
}
name
}
assignees(first: 50) {
nodes {
login
}
}
reviewRequests(first: 50) {
nodes {
requestedReviewer {
... on User {
login
}
... on Team {
name
}
}
}
}
closingIssuesReferences(first: 1) {
nodes {
id
title
number
url
repository {
owner {
login
}
name
}
}
}
}
}
}
}`,
{
searchQuery
}
).then((res) => res.search.nodes);
return pullRequests;
}
async function addComment(toolkit, issueId, commentBody) {
const comment = await toolkit.github.graphql(
/* GraphQL */
`
mutation addComment($issueId: ID!, $body: String!) {
addComment(input: {
subjectId: $issueId,
body: $body
}) {
commentEdge {
node {
id
author {
login
}
body
url
}
}
}
}
`,
{
issueId,
body: commentBody
}
).then((res) => res.addComment.commentEdge.node);
if (!comment || !comment.id) {
throw new Error(`Failed to create comment: ${JSON.stringify(comment)}`);
}
return comment;
}
function isUserNotFoundError(error) {
if (typeof error !== "object" || error === null || !("errors" in error)) {
return false;
}
const errors = error.errors;
return Array.isArray(errors) && errors.some((e) => e?.type === "NOT_FOUND");
}
async function getVerifiedDomainEmails(toolkit, login, organization) {
let res;
try {
res = await toolkit.github.graphql(
/* GraphQL */
`
query getVerifiedDomainEmails($login: String!, $organization: String!) {
user(login: $login) {
organizationVerifiedDomainEmails(login: $organization)
}
}
`,
{
login,
organization
}
);
} catch (error) {
if (isUserNotFoundError(error)) {
toolkit.core.info(`No GitHub user found for login ${login}; skipping email resolution.`);
return [];
}
throw error;
}
const emails = res.user?.organizationVerifiedDomainEmails ?? [];
toolkit.core.info(`Resolved verified domain emails for ${login} in ${organization}: ${JSON.stringify(emails)}`);
return emails;
}
async function getMilestoneByTitle(toolkit, repo, milestoneTitle, organization = "shopware") {
const milestones = await toolkit.github.paginate(toolkit.github.rest.issues.listMilestones, {
owner: organization,
repo
});
return milestones.find((x) => x.title == milestoneTitle);
}
const jiraHost = "shopware.atlassian.net";
const jiraBaseUrl = `https://${jiraHost}/rest/api/3`;
async function callJiraApi(toolkit, endpoint, requestBody) {
return await toolkit.fetch(`${jiraBaseUrl}${endpoint}`, {
method: "POST",
headers: {
"Authorization": `Basic ${Buffer.from(
`${process.env.JIRA_USERNAME}:${process.env.JIRA_API_TOKEN}`
).toString("base64")}`,
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
}).then((res) => res.json());
}
function isWebAPIPlatformError(error) {
return typeof error === "object" && error !== null && "code" in error && "data" in error;
}
class SlackClient {
webClient;
constructor(token) {
this.webClient = new WebClient(token);
}
async getUserByEmail(toolkit, email) {
try {
const response = await this.webClient.users.lookupByEmail({ email });
return response.user || null;
} catch (error) {
if (isWebAPIPlatformError(error)) {
if (error.code == ErrorCode.PlatformError) {
if (error.data.error === "users_not_found") {
toolkit.core.info(`No Slack user found for email ${email}.`);
} else {
toolkit.core.error("Failed to get user: " + error.data.error);
}
toolkit.core.debug(JSON.stringify(error.data));
} else {
throw error;
}
} else {
throw error;
}
}
return null;
}
async sendIMToUser(userId, message) {
await this.webClient.chat.postMessage({
channel: userId,
text: message,
blocks: [
{
type: "markdown",
text: message
}
]
});
}
}
async function cancelStuckWorkflows(toolkit, repo, organization = "shopware") {
const TIME_THRESHOLD = 2 * 3600;
const queuedRuns = await toolkit.github.rest.actions.listWorkflowRunsForRepo({
owner: organization,
repo,
status: "queued"
});
if (queuedRuns.data.total_count == 0) {
toolkit.core.warning("No queued workflow found.");
return;
}
const currentTime = Math.round((/* @__PURE__ */ new Date()).getTime() / 1e3);
for (const run of queuedRuns.data.workflow_runs) {
const createdAt = Math.floor(new Date(run.created_at).getTime() / 1e3);
const timeDiff = currentTime - createdAt;
const hoursQueued = Math.floor(timeDiff / 3600);
const minutesQueued = Math.floor(timeDiff % 3600 / 60);
if (timeDiff > TIME_THRESHOLD) {
toolkit.core.info(`Found old queued run: ${run.name} (ID: ${run.id}) - queued for ${hoursQueued}h ${minutesQueued}m`);
toolkit.core.info(`Force cancelling run ${run.id}...`);
try {
await toolkit.github.rest.actions.forceCancelWorkflowRun({
owner: organization,
repo,
run_id: run.id
});
toolkit.core.info(`\u2713 Successfully force-cancelled run ${run.id}`);
} catch (error) {
toolkit.core.error(`\u2717 Failed to force-cancel run ${run.id}: ${error}`);
}
} else {
toolkit.core.info(`Run ${run.name} (ID: ${run.id}) queued for ${hoursQueued}h ${minutesQueued}m - within threshold`);
}
}
}
async function checkMissingLiceneInRepos(toolkit, organization = "shopware") {
const excludeRepositories = [];
let currentCursor = null;
let reposWithoutLicenseCount = 0;
while (true) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getLicenseFile($cursor: String, $organization: String!){
organization(login: $organization) {
repositories(first: 100, after: $cursor) {
pageInfo {
startCursor
endCursor
hasNextPage
}
nodes {
name
visibility
object(expression: "HEAD:LICENSE") {
... on Blob {
byteSize
}
}
}
}
}
}
`,
{
cursor: currentCursor,
organization
}
);
res.organization.repositories.nodes.filter((x) => !excludeRepositories.includes(x.name) && x.visibility === "PUBLIC" && x.object === null).forEach((x) => {
toolkit.core.error(`${x.name} doesn't have a LICENSE`);
reposWithoutLicenseCount++;
});
if (!res.organization.repositories.pageInfo.hasNextPage) {
break;
}
currentCursor = res.organization.repositories.pageInfo.endCursor;
}
if (reposWithoutLicenseCount > 0) {
toolkit.core.setFailed(`${reposWithoutLicenseCount} repositories without LICENSE detected!`);
}
}
function isDryRun() {
return process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1";
}
async function rateLimitedRun(items, fn, max_concurrency = 10, delay_ms = 1e3) {
const chunks = [];
for (let i = 0; i < items.length; i += max_concurrency) {
chunks.push(items.slice(i, i + max_concurrency));
}
for (const chunk of chunks) {
await Promise.all(chunk.map((item) => fn(item)));
const nextChunk = chunks[chunks.indexOf(chunk) + 1];
if (nextChunk) {
await new Promise((resolve) => setTimeout(resolve, delay_ms));
}
}
}
const protectedReleaseBranchRegex = /^(saas\/2025\/\d+|\d+\.(\d+|x)(\.\d+|\.x)?(\.\d+|\.x)?)$/;
async function getOldBranches(toolkit, repo, excludeRegex = "", organization = "shopware") {
const DAYS_UNTIL_STALE = 6 * 30;
toolkit.core.info(`Getting branches for ${organization}/${repo}`);
const branches = [];
let currentCursor = null;
while (true) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getBranches(
$cursor: String
$organization: String!
$repository: String!
) {
repository(owner: $organization, name: $repository) {
refs(first: 100, after: $cursor, refPrefix: "refs/heads/") {
pageInfo {
startCursor
endCursor
hasNextPage
}
nodes {
name
target {
... on Commit {
committedDate
}
}
associatedPullRequests(first: 100) {
nodes {
number
}
}
}
}
}
}
`,
{
cursor: currentCursor,
repository: repo,
organization
}
);
branches.push(...res.repository.refs.nodes);
if (!res.repository.refs.pageInfo.hasNextPage) {
break;
}
currentCursor = res.repository.refs.pageInfo.endCursor;
}
const today = /* @__PURE__ */ new Date();
const cmpDate = new Date((/* @__PURE__ */ new Date()).setDate(today.getDate() - DAYS_UNTIL_STALE));
const oldBranches = [];
for (const branch of branches.filter((x) => x.associatedPullRequests.nodes.length == 0)) {
const regex = new RegExp(excludeRegex, "mg");
if (excludeRegex != "" && regex.test(branch.name)) {
continue;
}
const lastUpdate = new Date(branch.target.committedDate);
if (lastUpdate < cmpDate) {
oldBranches.push(branch.name);
toolkit.core.debug(`${branch.name} : ${lastUpdate} - ${excludeRegex != "" && regex.test(branch.name)}`);
}
}
return oldBranches;
}
async function cleanupBranches(toolkit, repo, organization = "shopware", excludeRegex = "") {
const branches = await getOldBranches(toolkit, repo, excludeRegex, organization);
if (!branches) {
toolkit.core.error("No old branches found!");
return;
}
toolkit.core.info(`Cleaning up ${branches.length} branch(es) with rate limiting...`);
if (isDryRun()) {
for (const branch of branches) {
toolkit.core.info(`Would delete ${branch}`);
}
return;
}
await rateLimitedRun(branches, async (branch) => {
toolkit.core.info(`Deleting ${branch}...`);
await toolkit.github.rest.git.deleteRef({
owner: organization,
repo,
ref: `heads/${branch}`
});
});
}
const docIssueReference = Buffer.from("doc-issue-created").toString("base64");
async function getDevelopmentIssueForPullRequest(toolkit, repo, pullRequestNumber, pullRequestHead, pullRequestAssignee) {
const pullRequests = await getPullRequests(
toolkit,
`repo:${repo} is:pr assignee:${pullRequestAssignee} head:${pullRequestHead}`
);
const matchingPullRequests = pullRequests.filter((pr) => pr.number === pullRequestNumber);
const developmentIssue = matchingPullRequests.length > 0 ? matchingPullRequests[0]?.closingIssuesReferences?.nodes[0] : null;
if (developmentIssue) {
return {
id: developmentIssue.id,
title: developmentIssue.title,
number: developmentIssue.number,
url: developmentIssue.url,
labels: [],
owner: developmentIssue.repository.owner.login,
repository: developmentIssue.repository.name
};
} else {
toolkit.core.info(`No development issue found for PR #${pullRequestNumber}`);
return null;
}
}
async function getEpicsInProgressByProject(toolkit, projectId) {
const res = await getIssuesByProject(toolkit, projectId);
return res.filter((item) => {
return item.status?.toLowerCase() === "in progress" && item.type?.toLowerCase() === "epic";
});
}
async function createDocIssueComment(toolkit, issueId, docIssueKey, content = null) {
const commentBody = `${content ?? "A documentation Task has been created for this issue:"} [${docIssueKey}](https://${jiraHost}/browse/${docIssueKey}). <!-- ${docIssueReference} -->`;
const comment = await addComment(toolkit, issueId, commentBody);
toolkit.core.info(`Created documentation issue reference comment: ${comment.url}`);
return comment;
}
async function hasDocIssueComment(toolkit, issueId) {
const comments = await getCommentsForIssue(toolkit, issueId);
for (const comment of comments) {
if (referencesDocIssue(comment)) {
return true;
}
}
return false;
}
function referencesDocIssue(comment) {
return comment.body.includes(docIssueReference);
}
async function manageNeedsTriageLabel(toolkit, labelable, needsTriageLabel, dryRun) {
const labels = labelable.labels.nodes.map((label) => label.name);
const hasNeedsTriage = labels.includes("needs-triage");
const hasDomainOrServiceLabel = labels.some(
(label) => label.startsWith("domain/") || label.startsWith("service/")
);
if (hasDomainOrServiceLabel && hasNeedsTriage) {
if (dryRun) {
toolkit.core.info(`Would remove needs-triage label from #${labelable.number}: ${labelable.title} (has domain/service label)`);
} else {
await toolkit.github.graphql(
/* GraphQL */
`
mutation removeLabel($labelableId: ID!, $labelIds: [ID!]!) {
removeLabelsFromLabelable(input: {
labelableId: $labelableId,
labelIds: $labelIds
}) {
clientMutationId
}
}
`,
{
labelableId: labelable.id,
labelIds: [needsTriageLabel.id]
}
);
toolkit.core.info(`Removed needs-triage label from #${labelable.number}: ${labelable.title} (has domain/service label)`);
}
} else if (!hasNeedsTriage && !hasDomainOrServiceLabel) {
if (dryRun) {
toolkit.core.info(`Would add needs-triage label to #${labelable.number}: ${labelable.title}`);
} else {
await addLabelToLabelable(toolkit, needsTriageLabel.id, labelable.id);
toolkit.core.info(`Added needs-triage label to #${labelable.number}: ${labelable.title}`);
}
}
}
async function cleanupNeedsTriage(toolkit, dryRun = false) {
const needsTriageLabel = await getLabelByName(toolkit, "shopware", "needs-triage");
if (!needsTriageLabel) {
throw new Error("Couldn't find the needs-triage label");
}
let processedItems = 0;
let hasNextPageIssues = true;
let cursorIssues = null;
while (hasNextPageIssues) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getOpenIssues($cursor: String) {
repository(owner: "shopware", name: "shopware") {
issues(first: 100, after: $cursor, states: OPEN) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
title
labels(first: 20) {
nodes {
name
}
}
}
}
}
}
`,
{
cursor: cursorIssues
}
);
const issues = res.repository.issues?.nodes ?? [];
hasNextPageIssues = res.repository.issues?.pageInfo.hasNextPage ?? false;
cursorIssues = res.repository.issues?.pageInfo.endCursor ?? null;
for (const item of issues) {
await manageNeedsTriageLabel(toolkit, item, needsTriageLabel, dryRun);
}
processedItems += issues.length;
toolkit.core.info(`Processed ${processedItems} items so far...`);
}
let hasNextPagePRs = true;
let cursorPRs = null;
while (hasNextPagePRs) {
const res = await toolkit.github.graphql(
/* GraphQL */
`
query getOpenPRs($cursor: String) {
repository(owner: "shopware", name: "shopware") {
pullRequests(first: 100, after: $cursor, states: OPEN) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
title
labels(first: 20) {
nodes {
name
}
}
}
}
}
}
`,
{
cursor: cursorPRs
}
);
const pullRequests = res.repository.pullRequests?.nodes ?? [];
hasNextPagePRs = res.repository.pullRequests?.pageInfo.hasNextPage ?? false;
cursorPRs = res.repository.pullRequests?.pageInfo.endCursor ?? null;
for (const item of pullRequests) {
await manageNeedsTriageLabel(toolkit, item, needsTriageLabel, dryRun);
}
processedItems += pullRequests.length;
toolkit.core.info(`Processed ${processedItems} items so far...`);
}
toolkit.core.info(`Finished processing ${processedItems} items`);
}
async function findWithProjectItems(toolkit) {
if (toolkit.context.payload.issue) {
return await findIssueWithProjectItems(toolkit, toolkit.context.payload.issue.number);
} else if (toolkit.context.payload.pull_request) {
return await findPRWithProjectItems(toolkit, toolkit.context.payload.pull_request.number);
} else {
throw new Error("only issue and pull_request events are supported");
}
}
async function linkClosingPR(toolkit, issueNumber, prReadToken, visibilityFilter = "PRIVATE", org = "shopware", repo = "shopware") {
const prReadClient = getOctokit(prReadToken);
const res = await prReadClient.graphql(
/* GraphQL */
`
query getClosingPR($owner: String!, $repo: String!, $issueNumber: Int!) {
repository(owner:$owner,name:$repo) {
issue(number:$issueNumber) {
timelineItems(first: 100, itemTypes: [CLOSED_EVENT]) {
nodes {
... on ClosedEvent {
closer {
... on PullRequest {
url
repository {
visibility
}
}
}
}
}
}
}
}
}
`,
{
owner: org,
repo,
issueNumber
}
);
const filteredNodes = res.repository.issue.timelineItems.nodes.filter(
(node) => node.closer?.repository?.visibility === visibilityFilter
);
if (filteredNodes.length == 0) {
toolkit.core.warning("No Closed Event of PRs found in timeline");
return;
}
if (filteredNodes.length > 1) {
const prUrls = filteredNodes.map((x) => x.closer?.url);
if (prUrls.length == 0) {
toolkit.core.warning("Can' get pr urls from timeline items");
return;
}
const comment = await toolkit.github.rest.issues.createComment({
owner: org,
repo,
issue_number: issueNumber,
body: `This issue was solved in a private repository by one of the PRs below, therefore the issue is marked as closed. It will be released in an upcoming version of the affected extension.
${prUrls.join("\n")}`
});
if (comment.status != 201) {
toolkit.core.error("Failed to create comment");
return;
}
toolkit.core.info(`Comment created: ${comment.data.url}`);
} else {
const prUrl = filteredNodes[0].closer?.url;
if (!prUrl) {
toolkit.core.warning("Can' get pr url from timeline item");
return;
}
const comment = await toolkit.github.rest.issues.createComment({
owner: org,
repo,
issue_number: issueNumber,
body: `This issue was solved in a private repository with PR ${prUrl}, therefore the issue is marked as closed. It will be released in an upcoming version of the affected extension.`
});
if (comment.status != 201) {
toolkit.core.error("Failed to create comment");
return;
}
toolkit.core.info(`Comment created: ${comment.data.url}`);
}
}
async function createDocumentationTask(toolkit, issue, documentationProjectId = 11806, description = null) {
const requestBody = buildDocumentationTaskRequest(issue, documentationProjectId, description);
const docTask = await callJiraApi(toolkit, "/issue", requestBody);
if (!docTask || !docTask.key) {
throw new Error(`Failed to create documentation task: ${JSON.stringify(docTask)}`);
}
toolkit.core.info(`Created documentation task in JIRA: https://${jiraHost}/browse/${docTask.key}`);
return docTask;
}
function buildDocumentationTaskRequest(issue, documentationProjectId, description) {
return {
fields: {
project: {
id: documentationProjectId
},
summary: `${issue.title}`,
description: buildDocumentationDescription(issue, description),
issuetype: {
name: "Task"
}
}
};
}
function buildDocumentationDescription(issue, description) {
return {
version: 1,
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: description ?? `This issue was created automatically.`
},
{
type: "text",
text: `
Please refer to the following links and/or related issues for more information:`
}
]
},
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: issue.url,
marks: [
{
type: "link",
attrs: {
href: issue.url
}
}
]
}
]
}
]
}
]
}
]
};
}
async function setMilestoneForPR(toolkit) {
const pr = toolkit.context.payload.pull_request;
if (!pr) {
throw new Error("This function can only be called on 'pull_request' workflows.");
}
const labels = pr.labels;
const { owner, repo } = toolkit.context.repo;
const milestoneLabel = labels.find((x) => x.name.startsWith("milestone/"));
if (!milestoneLabel) {
toolkit.core.info("No milestone labels found.");
return;
}
const milestoneTitle = milestoneLabel.name.split("/")[1];
let milestone = await getMilestoneByTitle(toolkit, toolkit.context.repo.repo, milestoneTitle, toolkit.context.repo.owner);
if (!milestone) {
toolkit.core.info(`Couldn't find a milestone with the title "${milestoneTitle}". Creating one...`);
const res = await toolkit.github.rest.issues.createMilestone({
owner: toolkit.context.repo.owner,
repo: toolkit.context.repo.repo,
title: milestoneTitle
});
milestone = res.data;
}
const linkedIssue = await getDevelopmentIssueForPullRequest(toolkit, `${owner}/${repo}`, pr.number, pr.head, pr.assignee);
if (linkedIssue && linkedIssue.number) {
toolkit.core.info(`Found linked issue (#${linkedIssue.number}), will add issue to milestone`);
await toolkit.github.rest.issues.update({
owner,
repo,
issue_number: linkedIssue.number,
milestone: milestone.number
});
return;
}
toolkit.core.info(`Havent't found an linked issue, will add pull request to milestone`);
await toolkit.github.rest.issues.update({
owner,
repo,
issue_number: pr.number,
milestone: milestone.number
});
}
const VERSION_REGEX = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
function bumpPatchVersion(version) {
const matches = VERSION_REGEX.exec(version);
if (!matches) {
return void 0;
}
return `${matches[1]}.${matches[2]}.${parseInt(matches[3], 10) + 1}.0`;
}
async function findOpenPullRequestsWithLabel(toolkit, owner, repo, label, baseRefName) {
const query = `
query ($owner: String!, $repo: String!, $label: String!, $baseRefName: String, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequests(labels: [$label], baseRefName: $baseRefName, states: OPEN, first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { number title }
}
}
}`;
const pullRequests = [];
let after = void 0;
do {
const res = await toolkit.github.graphql(query, { owner, repo, label, baseRefName: baseRefName ?? null, after });
pullRequests.push(...res.repository.pullRequests.nodes);
after = res.repository.pullRequests.pageInfo.hasNextPage ? res.repository.pullRequests.pageInfo.endCursor ?? void 0 : void 0;
} while (after);
return pullRequests;
}
async function moveMilestoneLabelsToNextVersion(toolkit, options) {
const owner = options.owner ?? "shopware";
const repo = options.repo ?? "shopware";
const dryRun = options.dryRun ?? isDryRun();
const nextVersion = bumpPatchVersion(options.version);
if (!nextVersion) {
throw new Error(`"${options.version}" is not a valid version (expected e.g. "6.7.10.0").`);
}
const currentLabel = `milestone/${options.version}`;
const nextLabel = `milestone/${nextVersion}`;
if (dryRun) {
toolkit.core.info("Running in DRY RUN mode - no labels will be created or changed.");
}
const pullRequests = await findOpenPullRequestsWithLabel(toolkit, owner, repo, currentLabel, options.baseRefName);
if (pullRequests.length === 0) {
toolkit.core.info(`No open PRs with label "${currentLabel}" found in ${owner}/${repo}.`);
return;
}
if (dryRun) {
toolkit.core.info(`${pullRequests.length} open PR(s) in ${owner}/${repo} would have "${currentLabel}" moved to "${nextLabel}":`);
for (const pr of pullRequests) {
toolkit.core.info(` - #${pr.number} ${pr.title}`);
}
return;
}
for (const pr of pullRequests) {
await toolkit.github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: currentLabel });
await toolkit.github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [nextLabel] });
toolkit.core.info(`Moved label on #${pr.number}: "${currentLabel}" -> "${nextLabel}" (${pr.title})`);
}
toolkit.core.info(`Moved "${currentLabel}" to "${nextLabel}" on ${pullRequests.length} PR(s) in ${owner}/${repo}.`);
}
async function updateMilestonesOnRelease(toolkit) {
if (process.env.TAG === void 0) {
toolkit.core.error("Environment variable TAG is missing!");
return 1;
}
const version = process.env.TAG.substring(1);
if (!VERSION_REGEX.test(version)) {
toolkit.core.error("Environment variable TAG has a wrong value!");
return 1;
}
await moveMilestoneLabelsToNextVersion(toolkit, { version });
}
async function setStatusInProjects(toolkit, props) {
const issue = await findWithProjectItems(toolkit);
for (const i in issue.projectItems) {
const item = issue.projectItems[i];
const projectInfo = await getProjectInfo(toolkit, { number: item.project.number });
const statusField = projectInfo.fields.find((field) => field.name == "Status");
const toStatusOption = statusField?.options.find((x) => x.name.toLowerCase() === props.toStatus.toLowerCase());
if (!toStatusOption) {
toolkit.core.debug(`Option "${props.toStatus}" not found in project ${item.project.number}`);
continue;
}
if (props.fromStatus instanceof RegExp) {
if (!item.fieldValueByName.name.match(props.fromStatus)) {
toolkit.core.debug(`skipping: issue/pr ${issue.number} status ${item.fieldValueByName} did not match ${props.fromStatus} in project ${item.project.number}`);
continue;
}
} else if (props.fromStatus && item.fieldValueByName.name.toLowerCase() !== props.fromStatus.toLowerCase()) {
toolkit.core.debug(`skipping: issue/pr ${issue.number} status != ${props.fromStatus} in project ${item.project.number}`);
continue;
}
toolkit.core.info(`get item in project ${item.project.number} for issue/pr ${issue.number}`);
const itemId = (await addProjectItem(toolkit, {
projectId: projectInfo.node_id,
issueId: issue.node_id
})).node_id;
await setFieldValue(toolkit, {
projectId: projectInfo.node_id,
itemId,
fieldId: statusField.id,
valueId: toStatusOption.id
});
}
}
async function syncPriorities(toolkit, excludeList = []) {
const issue = toolkit.context.payload.issue;
toolkit.core.debug(`Issue node ID: ${issue.node_id}`);
const priorityLabel = issue.labels.find(
(label) => label.name.startsWith("priority/")
)?.name;
if (!priorityLabel) {
return;
}
toolkit.core.info(`Found priority label: ${priorityLabel}`);
const priority = priorityLabel.split("/")[1];
toolkit.core.info(`Priority: ${priority}`);
const issueWithProjectItems = await findIssueWithProjectItems(toolkit, issue.number);
for (const projectItem of issueWithProjectItems.projectItems) {
if (excludeList.includes(projectItem.project.number)) {
toolkit.core.info(`The project number ${projectItem.project.number} is on the excludeList. skipping...`);
continue;
}
const projectInfo = await getProjectInfo(toolkit, { number: projectItem.project.number });
const priorityField = projectInfo.fields.find((field) => field.name == "Priority");
if (!priorityField) {
toolkit.core.info(`${projectInfo.title} doesn't have a priority field. skipping...`);
continue;
}
const priorityOption = priorityField?.options.find((option) => option.name == priority);
if (!priorityOption) {
toolkit.core.info(`${projectInfo.title} doesn't have the priority option ${priority}. skipping...`);
continue;
}
toolkit.core.info(`Setting priority for issue ${issue.number} on ${projectInfo.title} to ${priority}`);
await setFieldValue(toolkit, {
projectId: projectInfo.node_id,
itemId: projectItem.id,
fieldId: priorityField.id,
valueId: priorityOption.id
});
}
}
async function createDocumentationTasksForProjects(toolkit, projectNumbers, organization = "shopware", documentationProjectId = 11806, description = null, comment = null) {
for (const projectNumber of projectNumbers) {
const projectId = await getProjectIdByNumber(toolkit, projectNumber, organization);
const epicsInProgress = await getEpicsInProgressByProject(toolkit, projectId);
for (const epic of epicsInProgress) {
if (await hasDocIssueComment(toolkit, epic.id)) {
toolkit.core.info(`Skipping issue ${epic.url} because it already has a documentation issue reference.`);
continue;
}
const docTask = await createDocumentationTask(toolkit, epic, documentationProjectId, description);
await createDocIssueComment(toolkit, epic.id, docTask.key, comment);
}
}
}
async function markStaleIssues(toolkit, projectNumber, dryRun) {
const DAYS_UNTIL_STALE = 180;
const now = /* @__PURE__ */ new Date();
now.setDate(now.getDate() - DAYS_UNTIL_STALE);
const staleDate = now.toISOString().split("T")[0];
switch (Number(projectNumber)) {
case 27: {
const query = (
/* GraphQL */
`
query searchClosableIssues {
search(
type: ISSUE
first: 100
query: "repo:shopware/shopware is:issue state:open project:shopware/27 label:priority/low -label:lifecycle/AboutToClose -label:lifecycle/DoNotClose created:<=$staleDate"
) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
... on Issue {
id
title
number
url
parent {
issueType {
name
}
}
labels(first: 20) {
nodes {
name
}
}
projectItems(first: 10) {
nodes {
project {
number
}
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
}
}
}
}
}
}
}
`
);
const res = await toolkit.github.graphql(query.replace("$staleDate", staleDate), {
headers: {
"GraphQL-Features": "issue_types"
}
});
const issues = res.search.edges;
for (const issueNode of issues) {
const issue = issueNode.node;
const parentIssueType = issue.parent?.issueType.name;
const labels = issue.labels.nodes;
const priorityLabel = labels.find(
(label) => label.name.startsWith("priority/")
)?.name;
const statusInProject = issue.projectItems.nodes.find((projectItem) => projectItem.project.number == 27)?.fieldValueByName.name;
if ((priorityLabel && priorityLabel.split("/")[1] === "low" || statusInProject == "Backlog") && parentIssueType != "Epic") {
if (dryRun) {
toolkit.core.info(`Would set "${issue.title}" (${issue.url}) to lifecycle/AboutToClose`);
continue;
}
const aboutToCloseLabel = await getLabelByName(toolkit, "shopware", "lifecycle/AboutToClose");
if (!aboutToCloseLabel) {
throw Error("Couldn't find the AboutToClose label");
}
await addLabelToLabelable(toolkit, issue.id, aboutToCloseLabel.id);
}
}
break;
}
default:
throw new Error(`There is no query for the project with the number ${projectNumber}`);
}
}
async function closeStaleIssues(toolkit, dryRun) {
const DAYS_UNTIL_CLOSE = 30;
const now = /* @__PURE__ */ new Date();
now.setDate(now.getDate() - DAYS_UNTIL_CLOSE);
const closeDate = now.toISOString().split("T")[0];
const query = (
/* GraphQL */
`
query searchStaleIssues {
search(
type: ISSUE
first: 100
query: "repo:shopware/shopware is:issue state:open label:lifecycle/AboutToClose updated:<=$closeDate"
) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
... on Issue {
id
title
number
url
parent {
issueType {
name
}
}
labels(first: 20) {
nodes {
name
}
}
projectItems(first: 10) {
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
}
}
}
}
}
}
}
`
);
const res = await toolkit.github.graphql(query.replace("$closeDate", closeDate), {
headers: {
"GraphQL-Features": "issue_types"
}
});
const issues = res.search.edges;
for (const issueNode of issues) {
const issue = issueNode.node;
if (dryRun) {
toolkit.core.info(`Would close "${issue.title}" (${issue.url})`);
continue;
}
await closeIssue(toolkit, issue.id);
}
}
async function manageOldPullRequests(toolkit, organization = "shopware", days = 7, close = false, excludedRepositories = []) {
const pullRequests = await getPullRequests(
toolkit,
`org:${organization} is:pr is:open draft:false updated:<${new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString()}`
);
const closeMsg = `This pull request has been closed automatically. If you would like to continue working on it, please feel free to re-open it!`;
for (const pr of pullRequ