UNPKG

@shopware-ag/gh-project-automation

Version:
1,446 lines (1,432 loc) 54 kB
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