@shopware-ag/gh-project-automation
Version:
Project automation for shopware
1 lines • 129 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/api/github.ts","../src/api/jira.ts","../src/util/slack.ts","../src/api/slack.ts","../src/services/actions.ts","../src/util/dry_run.ts","../src/util/rate_limiting.ts","../src/services/branch.ts","../src/services/issue.ts","../src/services/jira.ts","../src/services/milestone.ts","../src/services/project.ts","../src/services/pull_request.ts","../src/services/release_schedule.ts","../src/services/slack.ts"],"sourcesContent":["import { GitHubComment, GitHubIssue, GitHubMilestone, Label, Toolkit } from \"../types\";\n\nexport async function closeIssue(toolkit: Toolkit, issueId: string, reason: string = \"NOT_PLANNED\") {\n const res = await toolkit.github.graphql(/* GraphQL */ `\n mutation closeIssue($issueId: ID!, $reason: IssueClosedStateReason!) {\n closeIssue(input: {\n issueId: $issueId,\n stateReason: $reason,\n }) {\n clientMutationId\n }\n }\n `,\n {\n issueId: issueId,\n reason: reason\n }\n );\n\n toolkit.core.debug(`closeIssue response: ${JSON.stringify(res)}`);\n}\n\nexport async function closePullRequest(toolkit: Toolkit, pullRequestId: string) {\n const res = await toolkit.github.graphql(/* GraphQL */ `\n mutation closeIssue($pullRequestId: ID!) {\n closePullRequest(input: {\n pullRequestId: $pullRequestId\n }) {\n clientMutationId\n }\n }\n `,\n {\n pullRequestId: pullRequestId\n }\n );\n\n toolkit.core.debug(`closePullRequest response: ${JSON.stringify(res)}`);\n}\n\nexport async function getLabelByName(toolkit: Toolkit, repository: string, labelName: string) {\n const res = await toolkit.github.graphql<{\n repository: {\n label?: Label\n }\n }>(/* GraphQL */ `\n query getLabelId($repository: String!, $labelName: String!) {\n repository(owner: \"shopware\", name: $repository) {\n label(name: $labelName) {\n id\n name\n url\n description\n color\n }\n }\n }\n `,\n {\n repository: repository,\n labelName: labelName\n });\n\n return res.repository.label;\n}\n\nexport async function addLabelToLabelable(toolkit: Toolkit, labelId: string, labelableId: string) {\n const res = await toolkit.github.graphql<{\n clientMutationId: string\n }>(/* GraphQL */ `\n mutation addLabels($labelId: ID!, $labelableId: ID!) {\n addLabelsToLabelable(input: {\n labelIds: [$labelId],\n labelableId: $labelableId\n }) {\n clientMutationId\n }\n }\n `,\n {\n labelId: labelId,\n labelableId: labelableId\n }\n );\n\n toolkit.core.debug(`addLabelToIssue response: ${JSON.stringify(res)}`);\n}\n\nexport async function findIssueWithProjectItems(toolkit: Toolkit, number: number) {\n const res = await toolkit.github.graphql<{\n repository: {\n issue: {\n projectItems: {\n nodes: [{\n id: string,\n project: { number: number },\n fieldValueByName: { name: string },\n }]\n },\n id: string,\n number: number,\n }\n }\n }>(/* GraphQL */ `\n query findIssueWithProjectItems($number: Int!) {\n repository(owner: \"shopware\", name: \"shopware\") {\n issue(number: $number) {\n projectItems(first: 20) {\n nodes {\n id\n project {\n number\n }\n fieldValueByName(name: \"Status\") {\n ... on ProjectV2ItemFieldSingleSelectValue {\n name\n }\n }\n }\n }\n id\n number\n }\n }\n }\n `,\n {\n number,\n }\n )\n\n toolkit.core.debug(`findIssueInProject response: ${JSON.stringify(res)}`)\n\n return {\n node_id: res.repository.issue.id,\n number: res.repository.issue.number,\n projectItems: res.repository.issue.projectItems.nodes,\n }\n}\n\nexport async function findPRWithProjectItems(toolkit: Toolkit, number: number) {\n const res = await toolkit.github.graphql<{\n repository: {\n pullRequest: {\n projectItems: {\n nodes: [{\n project: { number: number },\n fieldValueByName: { name: string },\n }]\n },\n id: string,\n number: number,\n }\n }\n }>(/* GraphQL */ `\n query findPRWithProjectItems($number: Int!) {\n repository(owner: \"shopware\", name: \"shopware\") {\n pullRequest(number: $number) {\n projectItems(first: 20) {\n nodes {\n project {\n number\n }\n fieldValueByName(name: \"Status\") {\n ... on ProjectV2ItemFieldSingleSelectValue {\n name\n }\n }\n }\n }\n id\n number\n }\n }\n }\n `,\n {\n number,\n }\n )\n\n toolkit.core.debug(`findIssueInProject response: ${JSON.stringify(res)}`)\n\n return {\n node_id: res.repository.pullRequest.id,\n number: res.repository.pullRequest.number,\n projectItems: res.repository.pullRequest.projectItems.nodes,\n }\n}\n\nexport async function setFieldValue(toolkit: Toolkit, data: {\n projectId: string,\n itemId: string,\n fieldId: string,\n valueId: string\n}) {\n const res = await toolkit.github.graphql(/* GraphQL */ `\n mutation setFieldValue($projectId: ID!, $itemId: ID!, $fieldId: ID!, $valueId: String!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: {singleSelectOptionId: $valueId}\n }) {\n projectV2Item {\n id\n }\n }\n }\n `,\n data\n )\n\n toolkit.core.debug(`setFieldValue response: ${JSON.stringify(res)}`)\n\n return res\n}\n\nexport async function getProjectInfo(toolkit: Toolkit, data: {\n number: number,\n organization?: string\n}) {\n type getProjectInfo = {\n organization: {\n projectV2: {\n id: string,\n title: string,\n fields: {\n nodes: [{\n id: string,\n name: string,\n options: [{\n id: string,\n name: string\n }]\n }]\n }\n }\n }\n };\n const res = await toolkit.github.graphql<getProjectInfo>(/* GraphQL */ `\n query getProjectInfo($organization: String!, $projectNumber: Int!) {\n organization(login: $organization) {\n projectV2(number: $projectNumber) {\n id\n title\n fields(first: 20) {\n nodes {\n ... on ProjectV2SingleSelectField {\n id\n name\n options {\n id\n name\n }\n }\n }\n }\n }\n }\n }\n `,\n {\n organization: data.organization ?? \"shopware\",\n projectNumber: data.number,\n }\n )\n\n toolkit.core.debug(`getProjectInfo response: ${JSON.stringify(res)}`)\n\n const project = res.organization.projectV2\n\n return {\n node_id: project.id,\n title: project.title,\n fields: project.fields.nodes,\n }\n}\n\nexport async function addProjectItem(toolkit: Toolkit, data: {\n projectId: string,\n issueId: string\n}) {\n const res = await toolkit.github.graphql<{\n addProjectV2ItemById: { item: { id: string } }\n }>(/* GraphQL */ `\n mutation addProjectItem($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }\n `,\n {\n projectId: data.projectId,\n contentId: data.issueId\n }\n );\n\n toolkit.core.debug(`addCard response: ${JSON.stringify(res)}`)\n\n return {\n node_id: res.addProjectV2ItemById.item.id\n }\n}\n\n/**\n * getProjectIdByNumber fetches the project ID for a given project number.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param number - The project number to get the ID for.\n * @param organization - The organization name whose projects to consider.\n */\nexport async function getProjectIdByNumber(toolkit: Toolkit, number: number, organization: string | null = \"shopware\") {\n const res = await toolkit.github.graphql<{\n organization: {\n projectV2: {\n id: string\n }\n }\n }>(/* GraphQL */ `\n query getProjectIdByNumber($organization: String!, $number: Int!) {\n organization(login: $organization) {\n projectV2(number: $number) {\n id\n }\n }\n }\n `,\n {\n organization: organization,\n number: number\n });\n\n return res.organization.projectV2.id;\n}\n\n/**\n * getIssuesByProject fetches all issues from a project.\n *\n * @remarks\n * This function uses pagination to fetch all issues from a project.\n * It will keep fetching until all issues are retrieved.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param projectId - The ID of the project to fetch issues from.\n * @param cursor - The cursor for pagination.\n * @param carry - The issues already fetched.\n */\nexport async function getIssuesByProject(toolkit: Toolkit, projectId: string, cursor: string | null = null, carry: GitHubIssue[] | null = null): Promise<GitHubIssue[]> {\n const res = await toolkit.github.graphql<{\n node: {\n items: {\n pageInfo: {\n startCursor: string,\n endCursor: string,\n hasPreviousPage: boolean,\n hasNextPage: boolean\n },\n nodes: [{\n fieldValueByName: {\n name: string\n },\n content: {\n id: string,\n title: string,\n number: number,\n url: string,\n issueType?: {\n name: string\n }\n }\n }]\n }\n }\n }>(/* GraphQL */ `\n query getIssuesByProject($projectId: ID!, $count: Int, $cursor: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: $count, after: $cursor) {\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n nodes {\n fieldValueByName(name: \"Status\") {\n ... on ProjectV2ItemFieldSingleSelectValue {\n name\n }\n }\n content {\n ... on Issue {\n id\n title\n number\n url\n issueType {\n name\n }\n }\n }\n }\n }\n }\n }\n }\n `,\n {\n projectId,\n count: 100,\n cursor: cursor\n });\n\n const issues = res.node.items.nodes.map((item) => {\n return {\n id: item.content.id,\n title: item.content.title,\n number: item.content.number,\n url: item.content.url,\n status: item.fieldValueByName?.name,\n labels: [],\n type: item.content?.issueType?.name\n }\n });\n\n if (res.node.items.pageInfo.hasNextPage) {\n return await getIssuesByProject(toolkit, projectId, res.node.items.pageInfo.endCursor, [...issues, ...(carry ?? [])]);\n } else {\n return [...issues, ...(carry ?? [])];\n }\n}\n\n/**\n * getCommentsForIssue fetches all comments for a given issue.\n *\n * @remarks\n * This function uses pagination to fetch all comments for an issue.\n * It will keep fetching until all comments are retrieved.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param issueId - The ID of the issue to fetch comments for.\n * @param cursor - The cursor for pagination.\n * @param carry - The comments already fetched.\n */\nexport async function getCommentsForIssue(toolkit: Toolkit, issueId: string, cursor: string | null = null, carry: GitHubComment[] | null = null) {\n const res = await toolkit.github.graphql<{\n node: {\n comments: {\n pageInfo: {\n startCursor: string,\n endCursor: string,\n hasPreviousPage: boolean,\n hasNextPage: boolean\n },\n nodes: GitHubComment[]\n }\n }\n }>(/* GraphQL */ `\n query getCommentsForIssue($issueId: ID!, $count: Int, $cursor: String) {\n node(id: $issueId) {\n ... on Issue {\n comments(first: $count, after: $cursor) {\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n nodes {\n id\n author {\n login\n }\n body\n }\n }\n }\n }\n }\n `,\n {\n issueId,\n count: 100,\n cursor: cursor\n })\n\n const comments = res.node.comments.nodes;\n\n if (res.node.comments.pageInfo.hasNextPage) {\n return await getCommentsForIssue(toolkit, issueId, res.node.comments.pageInfo.endCursor, [...comments, ...(carry ?? [])]);\n } else {\n return [...comments, ...(carry ?? [])];\n }\n}\n\n/**\n * Gets pull requests matching the given search criteria\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param searchQuery - The GitHub search query to use\n */\nexport async function getPullRequests(toolkit: Toolkit, searchQuery: string) {\n const pullRequests = await toolkit.github.graphql<\n {\n search: {\n pageInfo: {\n startCursor: string,\n endCursor: string,\n hasPreviousPage: boolean,\n hasNextPage: boolean\n },\n nodes: [\n {\n id: string,\n title: string,\n number: number,\n url: string,\n repository: {\n owner: {\n login: string\n },\n name: string\n },\n assignees: {\n nodes: [{\n login: string\n }]\n },\n reviewRequests: {\n nodes: [{\n requestedReviewer: {\n login?: string,\n name?: string\n }\n }]\n },\n closingIssuesReferences: {\n nodes: [{\n id: string,\n title: string,\n number: number,\n url: string,\n repository: {\n owner: {\n login: string\n },\n name: string\n }\n }]\n }\n }\n ]\n }\n }\n >(/* GraphQL */ `\n query findPullRequests($searchQuery: String!) {\n search(query: $searchQuery, type: ISSUE, first: 50) {\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n nodes {\n ... on PullRequest {\n id\n title\n number\n url\n repository {\n owner {\n login\n }\n name\n }\n assignees(first: 50) {\n nodes {\n login\n }\n }\n reviewRequests(first: 50) {\n nodes {\n requestedReviewer {\n ... on User {\n login\n }\n ... on Team {\n name\n }\n }\n }\n }\n closingIssuesReferences(first: 1) {\n nodes {\n id\n title\n number\n url\n repository {\n owner {\n login\n }\n name\n }\n }\n }\n }\n }\n }\n }`,\n {\n searchQuery\n }).then(res => res.search.nodes);\n\n return pullRequests;\n}\n\nexport async function addComment(toolkit: Toolkit, issueId: string, commentBody: string): Promise<GitHubComment> {\n const comment = await toolkit.github.graphql<{\n addComment: {\n commentEdge: {\n node: GitHubComment\n }\n }\n }>(/* GraphQL */ `\n mutation addComment($issueId: ID!, $body: String!) {\n addComment(input: {\n subjectId: $issueId,\n body: $body\n }) {\n commentEdge {\n node {\n id\n author {\n login\n }\n body\n url\n }\n }\n }\n }\n `,\n {\n issueId,\n body: commentBody\n }).then(res => res.addComment.commentEdge.node);\n\n if (!comment || !comment.id) {\n throw new Error(`Failed to create comment: ${JSON.stringify(comment)}`);\n }\n\n return comment;\n}\n\n/**\n * isUserNotFoundError reports whether a GraphQL error was caused by the queried\n * user not existing (GitHub replies with a `NOT_FOUND` error and `data.user: null`).\n */\nfunction isUserNotFoundError(error: unknown): boolean {\n if (typeof error !== 'object' || error === null || !('errors' in error)) {\n return false;\n }\n\n const errors = (error as { errors?: Array<{ type?: string }> }).errors;\n\n return Array.isArray(errors) && errors.some(e => e?.type === 'NOT_FOUND');\n}\n\n/**\n * getVerifiedDomainEmails fetches the verified domain emails for a user account associated with an enterprise.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param login - The login of the user.\n * @param organization - The organization name whose verified domains to consider.\n */\nexport async function getVerifiedDomainEmails(toolkit: Toolkit, login: string, organization: string): Promise<string[]> {\n let res: { user: { organizationVerifiedDomainEmails: string[] } | null };\n\n try {\n res = await toolkit.github.graphql(/* GraphQL */ `\n query getVerifiedDomainEmails($login: String!, $organization: String!) {\n user(login: $login) {\n organizationVerifiedDomainEmails(login: $organization)\n }\n }\n `,\n {\n login,\n organization\n });\n } catch (error: unknown) {\n // A bot actor (e.g. `shopware-saas[bot]`) is not a GitHub user, so the query\n // resolves with `data.user: null` and a NOT_FOUND error, which Octokit throws.\n // Treat that as \"no verified emails\" so callers can fall back gracefully.\n if (isUserNotFoundError(error)) {\n toolkit.core.info(`No GitHub user found for login ${login}; skipping email resolution.`);\n\n return [];\n }\n\n throw error;\n }\n\n const emails = res.user?.organizationVerifiedDomainEmails ?? [];\n toolkit.core.info(`Resolved verified domain emails for ${login} in ${organization}: ${JSON.stringify(emails)}`);\n\n return emails;\n}\n\n/**\n * getMilestoneByTitle fetches a milestone by it's title.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param repo - The name of the repository\n * @param milestoneTitle the title of the milestone\n * @param organization - The organization name of the repository\n */\nexport async function getMilestoneByTitle(toolkit: Toolkit, repo: string, milestoneTitle: string, organization: string = \"shopware\"): Promise<GitHubMilestone | undefined> {\n const milestones = await toolkit.github.paginate(toolkit.github.rest.issues.listMilestones, {\n owner: organization,\n repo: repo\n });\n\n return milestones.find(x => x.title == milestoneTitle)\n}\n","import {Toolkit} from \"../types\";\n\nexport const jiraHost = \"shopware.atlassian.net\";\nexport const jiraBaseUrl = `https://${jiraHost}/rest/api/3`;\n\n/**\n * Makes an API call to JIRA with the provided endpoint and request body.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param endpoint - The JIRA API endpoint to call.\n * @param requestBody - The request body to send with the API call.\n *\n * @return The response from the JIRA API.\n */\nexport async function callJiraApi(toolkit: Toolkit, endpoint: string, requestBody: object) {\n return await toolkit.fetch(`${jiraBaseUrl}${endpoint}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Basic ${Buffer.from(\n `${process.env.JIRA_USERNAME}:${process.env.JIRA_API_TOKEN}`,\n ).toString('base64')}`,\n 'Accept': 'application/json',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(requestBody)\n }).then(res => res.json());\n}\n","import { WebAPIPlatformError } from \"@slack/web-api\";\n\nexport function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'code' in error &&\n 'data' in error\n );\n}\n","import { ErrorCode, WebClient } from '@slack/web-api';\nimport { User } from '@slack/web-api/dist/types/response/UsersLookupByEmailResponse';\nimport { Toolkit } from '../types';\nimport { isWebAPIPlatformError } from '../util/slack';\n\nexport class SlackClient {\n private webClient: WebClient;\n\n constructor(token: string) {\n this.webClient = new WebClient(token);\n }\n\n async getUserByEmail(toolkit: Toolkit, email: string): Promise<User | null> {\n try {\n const response = await this.webClient.users.lookupByEmail({ email });\n return response.user || null;\n } catch (error: unknown) {\n if (isWebAPIPlatformError(error)) {\n if (error.code == ErrorCode.PlatformError) {\n // `users_not_found` just means no Slack account uses this email.\n // That is an expected outcome while probing multiple emails, so\n // don't surface it as an error annotation.\n if (error.data.error === \"users_not_found\") {\n toolkit.core.info(`No Slack user found for email ${email}.`);\n } else {\n toolkit.core.error(\"Failed to get user: \" + error.data.error);\n }\n toolkit.core.debug(JSON.stringify(error.data));\n } else {\n throw error;\n }\n } else {\n throw error;\n }\n }\n\n return null;\n }\n\n async sendIMToUser(userId: string, message: string): Promise<void> {\n await this.webClient.chat.postMessage({\n channel: userId,\n text: message,\n blocks: [\n {\n type: 'markdown',\n text: message\n }\n ]\n });\n }\n}\n","import { Toolkit } from \"../types\";\n\nexport async function cancelStuckWorkflows(toolkit: Toolkit, repo: string, organization: string = \"shopware\") {\n const TIME_THRESHOLD = 2 * 3600;\n\n const queuedRuns = await toolkit.github.rest.actions.listWorkflowRunsForRepo({\n owner: organization,\n repo: repo,\n status: \"queued\"\n });\n\n if (queuedRuns.data.total_count == 0) {\n toolkit.core.warning(\"No queued workflow found.\");\n return;\n }\n\n const currentTime = Math.round(new Date().getTime() / 1000);\n\n for (const run of queuedRuns.data.workflow_runs) {\n const createdAt = Math.floor(new Date(run.created_at).getTime() / 1000);\n const timeDiff = currentTime - createdAt;\n const hoursQueued = Math.floor(timeDiff / 3600);\n const minutesQueued = Math.floor((timeDiff % 3600) / 60);\n\n if (timeDiff > TIME_THRESHOLD) {\n toolkit.core.info(`Found old queued run: ${run.name} (ID: ${run.id}) - queued for ${hoursQueued}h ${minutesQueued}m`);\n toolkit.core.info(`Force cancelling run ${run.id}...`);\n\n try {\n await toolkit.github.rest.actions.forceCancelWorkflowRun({\n owner: organization,\n repo: repo,\n run_id: run.id\n });\n toolkit.core.info(`✓ Successfully force-cancelled run ${run.id}`);\n } catch (error) {\n toolkit.core.error(`✗ Failed to force-cancel run ${run.id}: ${error}`);\n }\n } else {\n toolkit.core.info(`Run ${run.name} (ID: ${run.id}) queued for ${hoursQueued}h ${minutesQueued}m - within threshold`);\n }\n }\n}\n\nexport async function checkMissingLiceneInRepos(toolkit: Toolkit, organization: string = \"shopware\") {\n const excludeRepositories: Array<string> = [];\n\n let currentCursor = null;\n type ObjectsResponse = {\n organization: {\n repositories: {\n pageInfo: {\n startCursor: string,\n endCursor: string,\n hasNextPage: boolean\n },\n nodes: Array<{\n name: string,\n visibility: string,\n object: { byteSize: number } | null\n }>\n }\n }\n };\n\n let reposWithoutLicenseCount = 0;\n\n while (true) {\n const res: ObjectsResponse = await toolkit.github.graphql<ObjectsResponse>(/* GraphQL */ `\n query getLicenseFile($cursor: String, $organization: String!){\n organization(login: $organization) {\n repositories(first: 100, after: $cursor) {\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n }\n nodes {\n name\n visibility\n object(expression: \"HEAD:LICENSE\") {\n ... on Blob {\n byteSize\n }\n }\n }\n }\n }\n }\n `,\n {\n cursor: currentCursor,\n organization\n }\n );\n res.organization.repositories.nodes.filter(x => !excludeRepositories.includes(x.name) && x.visibility === \"PUBLIC\" && x.object === null).forEach(x => {\n toolkit.core.error(`${x.name} doesn't have a LICENSE`); reposWithoutLicenseCount++;\n });\n if (!res.organization.repositories.pageInfo.hasNextPage) {\n break;\n }\n currentCursor = res.organization.repositories.pageInfo.endCursor;\n\n }\n\n if (reposWithoutLicenseCount > 0) {\n toolkit.core.setFailed(`${reposWithoutLicenseCount} repositories without LICENSE detected!`);\n }\n}\n","import {Toolkit} from \"../types\";\n\nexport function dontExecuteOnDryRun(toolkit: Toolkit, callback: () => void): void {\n if (isDryRun()) {\n toolkit.core.info('Dry run mode is enabled. Skipping execution.');\n return;\n }\n\n callback();\n}\n\nexport function throwOnDryRun(toolkit: Toolkit, msg: string | null = null): void {\n if (isDryRun()) {\n if (msg) {\n toolkit.core.info(msg);\n }\n\n throw new Error('Dry run mode is enabled.');\n }\n}\n\nexport function rejectOnDryRun(toolkit: Toolkit, msg: string | null = null): Promise<string | void> {\n return new Promise((resolve, reject) => {\n if (isDryRun()) {\n if (msg) {\n toolkit.core.info(msg);\n }\n\n reject('Dry run mode is enabled.');\n } else {\n resolve();\n }\n });\n}\n\nexport function isDryRun(): boolean {\n return process.env.DRY_RUN === 'true' || process.env.DRY_RUN === '1';\n}\n","export async function rateLimitedRun<T>(items: T[], fn: (item: T) => Promise<void>, max_concurrency: number = 10, delay_ms: number = 1000): Promise<void> {\n // Split items into chunks of max_concurrency\n const chunks = [];\n for (let i = 0; i < items.length; i += max_concurrency) {\n chunks.push(items.slice(i, i + max_concurrency));\n }\n\n for (const chunk of chunks) {\n // Run all items in the chunk concurrently\n await Promise.all(chunk.map(item => fn(item)));\n\n // Add a small delay between batches to respect the secondary rate limit\n const nextChunk = chunks[chunks.indexOf(chunk) + 1];\n if (nextChunk) {\n await new Promise(resolve => setTimeout(resolve, delay_ms));\n }\n }\n}\n\n","import { Toolkit } from \"../types\";\nimport { isDryRun } from \"../util/dry_run\";\nimport { rateLimitedRun } from \"../util/rate_limiting\";\n\n/**\n * Branches that must never be deleted by {@link cleanupBranches}. Matches\n * long-lived release branches such as `6.5`, `6.5.0`, `6.6.x` and SaaS release\n * branches like `saas/2025/12`.\n *\n * This is the single source of truth for the exclude pattern used by the\n * branch-cleanup workflow; it is exported so it can be unit-tested and imported\n * by the workflow instead of being duplicated as a YAML string literal.\n */\nexport const protectedReleaseBranchRegex = /^(saas\\/2025\\/\\d+|\\d+\\.(\\d+|x)(\\.\\d+|\\.x)?(\\.\\d+|\\.x)?)$/;\n\nexport async function getOldBranches(toolkit: Toolkit, repo: string, excludeRegex: string | RegExp = \"\", organization: string = \"shopware\"): Promise<string[] | null> {\n const DAYS_UNTIL_STALE = 6 * 30;\n\n toolkit.core.info(`Getting branches for ${organization}/${repo}`);\n\n const branches = [];\n\n let currentCursor = null;\n\n type BranchResponse = {\n repository: {\n refs: {\n pageInfo: {\n startCursor: string,\n endCursor: string,\n hasNextPage: boolean\n },\n nodes: Array<{\n name: string,\n target: {\n committedDate: string\n },\n associatedPullRequests: {\n nodes: Array<{\n number: number\n }>\n }\n }>\n }\n }\n };\n\n while (true) {\n const res: BranchResponse = await toolkit.github.graphql<BranchResponse>(/* GraphQL */ `\n query getBranches(\n $cursor: String\n $organization: String!\n $repository: String!\n ) {\n repository(owner: $organization, name: $repository) {\n refs(first: 100, after: $cursor, refPrefix: \"refs/heads/\") {\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n }\n nodes {\n name\n target {\n ... on Commit {\n committedDate\n }\n }\n associatedPullRequests(first: 100) {\n nodes {\n number\n }\n }\n }\n }\n }\n }\n `,\n {\n cursor: currentCursor,\n repository: repo,\n organization: organization\n });\n\n branches.push(...res.repository.refs.nodes);\n\n if (!res.repository.refs.pageInfo.hasNextPage) {\n break;\n }\n currentCursor = res.repository.refs.pageInfo.endCursor;\n }\n\n const today = new Date();\n const cmpDate = new Date(new Date().setDate(today.getDate() - DAYS_UNTIL_STALE));\n\n const oldBranches: string[] = [];\n\n for (const branch of branches.filter(x => x.associatedPullRequests.nodes.length == 0)) {\n const regex = new RegExp(excludeRegex, \"mg\");\n if (excludeRegex != \"\" && regex.test(branch.name)) {\n continue;\n }\n const lastUpdate = new Date(branch.target.committedDate);\n\n if (lastUpdate < cmpDate) {\n\n oldBranches.push(branch.name);\n\n toolkit.core.debug(`${branch.name} : ${lastUpdate} - ${(excludeRegex != \"\" && regex.test(branch.name))}`);\n }\n }\n\n return oldBranches;\n}\n\nexport async function cleanupBranches(toolkit: Toolkit, repo: string, organization: string = \"shopware\", excludeRegex: string | RegExp = \"\") {\n const branches = await getOldBranches(toolkit, repo, excludeRegex, organization);\n if (!branches) {\n toolkit.core.error(\"No old branches found!\");\n return;\n }\n\n toolkit.core.info(`Cleaning up ${branches.length} branch(es) with rate limiting...`);\n\n if (isDryRun()) {\n for (const branch of branches) {\n toolkit.core.info(`Would delete ${branch}`);\n }\n return;\n }\n\n // Rate-limit the actual deletions\n await rateLimitedRun(branches, async branch => {\n toolkit.core.info(`Deleting ${branch}...`);\n await toolkit.github.rest.git.deleteRef({\n owner: organization,\n repo: repo,\n ref: `heads/${branch}`\n });\n });\n}\n","import {\n GitHubComment,\n GitHubIssue,\n Label,\n Labelable,\n QueryResponse,\n Toolkit\n} from \"../types\";\n\nimport {\n addComment,\n addLabelToLabelable,\n findIssueWithProjectItems,\n findPRWithProjectItems,\n getCommentsForIssue,\n getIssuesByProject,\n getLabelByName,\n getPullRequests,\n jiraHost\n} from \"../api\";\nimport { getOctokit } from \"@actions/github\";\n\nexport const docIssueReference = Buffer.from(\"doc-issue-created\").toString(\"base64\");\n\n/**\n * getDevelopmentIssueForPullRequest fetches the development issue linked to a pull request.\n *\n * @remarks\n * This function searches for pull requests in the Shopware organization that match the given head and assignee.\n * It retrieves the first closing issue reference from the matching pull requests.\n * If a matching development issue is found, it returns the issue details; otherwise, it returns null.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param repo - The repository to search in, formatted as \"owner/repo\".\n * @param pullRequestNumber - The number of the pull request to search for.\n * @param pullRequestHead - The head branch of the pull request.\n * @param pullRequestAssignee - The assignee of the pull request.\n */\nexport async function getDevelopmentIssueForPullRequest(toolkit: Toolkit, repo: string, pullRequestNumber: number, pullRequestHead: string, pullRequestAssignee: string): Promise<GitHubIssue | null> {\n const pullRequests = await getPullRequests(\n toolkit,\n `repo:${repo} is:pr assignee:${pullRequestAssignee} head:${pullRequestHead}`\n );\n\n const matchingPullRequests = pullRequests.filter(pr => pr.number === pullRequestNumber);\n const developmentIssue = matchingPullRequests.length > 0 ? matchingPullRequests[0]?.closingIssuesReferences?.nodes[0] : null;\n\n if (developmentIssue) {\n return {\n id: developmentIssue.id,\n title: developmentIssue.title,\n number: developmentIssue.number,\n url: developmentIssue.url,\n labels: [],\n owner: developmentIssue.repository.owner.login,\n repository: developmentIssue.repository.name\n };\n } else {\n toolkit.core.info(`No development issue found for PR #${pullRequestNumber}`);\n return null;\n }\n}\n\n/**\n * getEpicsInProgressByProject fetches all epics in progress from a project.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param projectId - The ID of the project to fetch issues from.\n */\nexport async function getEpicsInProgressByProject(toolkit: Toolkit, projectId: string): Promise<GitHubIssue[]> {\n const res = await getIssuesByProject(toolkit, projectId);\n\n return res.filter((item) => {\n return item.status?.toLowerCase() === \"in progress\" && item.type?.toLowerCase() === \"epic\";\n })\n}\n\n/**\n * createDocIssueComment creates a comment on a GitHub issue that references a documentation issue.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param issueId - The ID of the issue to comment on.\n * @param docIssueKey - The key of the documentation issue to reference.\n * @param content - The comment to add.\n */\nexport async function createDocIssueComment(toolkit: Toolkit, issueId: string, docIssueKey: string, content: string | null = null): Promise<GitHubComment> {\n const commentBody = `${content ?? \"A documentation Task has been created for this issue:\"} [${docIssueKey}](https://${jiraHost}/browse/${docIssueKey}). <!-- ${docIssueReference} -->`;\n\n const comment = await addComment(toolkit, issueId, commentBody);\n\n toolkit.core.info(`Created documentation issue reference comment: ${comment.url}`);\n\n return comment;\n}\n\n/**\n * hasDocIssueComment checks if a GitHub issue has a comment that references a documentation issue.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param issueId - The ID of the issue to check.\n */\nexport async function hasDocIssueComment(toolkit: Toolkit, issueId: string) {\n const comments = await getCommentsForIssue(toolkit, issueId);\n\n for (const comment of comments) {\n if (referencesDocIssue(comment)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * referencesDocIssue checks if a comment contains a link to a documentation issue.\n *\n * @param comment - The comment to check.\n */\nexport function referencesDocIssue(comment: GitHubComment) {\n return comment.body.includes(docIssueReference)\n}\n\nasync function manageNeedsTriageLabel(toolkit: Toolkit, labelable: Labelable, needsTriageLabel: Label, dryRun: boolean) {\n const labels = labelable.labels.nodes.map((label: {\n name: string\n }) => label.name);\n const hasNeedsTriage = labels.includes(\"needs-triage\");\n const hasDomainOrServiceLabel = labels.some((label: string) =>\n label.startsWith(\"domain/\") || label.startsWith(\"service/\")\n );\n\n if (hasDomainOrServiceLabel && hasNeedsTriage) {\n if (dryRun) {\n toolkit.core.info(`Would remove needs-triage label from #${labelable.number}: ${labelable.title} (has domain/service label)`);\n } else {\n await toolkit.github.graphql(/* GraphQL */ `\n mutation removeLabel($labelableId: ID!, $labelIds: [ID!]!) {\n removeLabelsFromLabelable(input: {\n labelableId: $labelableId,\n labelIds: $labelIds\n }) {\n clientMutationId\n }\n }\n `, {\n labelableId: labelable.id,\n labelIds: [needsTriageLabel.id]\n });\n toolkit.core.info(`Removed needs-triage label from #${labelable.number}: ${labelable.title} (has domain/service label)`);\n }\n } else if (!hasNeedsTriage && !hasDomainOrServiceLabel) {\n if (dryRun) {\n toolkit.core.info(`Would add needs-triage label to #${labelable.number}: ${labelable.title}`);\n } else {\n await addLabelToLabelable(toolkit, needsTriageLabel.id, labelable.id);\n toolkit.core.info(`Added needs-triage label to #${labelable.number}: ${labelable.title}`);\n }\n }\n}\n\n/**\n * Cleans up needs-triage in issues and pull requests\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param dryRun - If true, only log what would be done without making changes.\n *\n * @remarks\n * This function finds all open issues and pull requests and ensures they have either:\n * - The needs-triage label, or\n * - A label starting with 'domain/' or 'service/'\n * - If an item has a `domain/` or `service/` label, remove the `needs-triage` label\n *\n * Closed items are ignored. The function handles pagination to process all items.\n */\nexport async function cleanupNeedsTriage(toolkit: Toolkit, dryRun: boolean = false) {\n const needsTriageLabel = await getLabelByName(toolkit, \"shopware\", \"needs-triage\");\n if (!needsTriageLabel) {\n throw new Error(\"Couldn't find the needs-triage label\");\n }\n\n let processedItems = 0;\n\n // Process issues\n let hasNextPageIssues = true;\n let cursorIssues: string | null = null;\n\n while (hasNextPageIssues) {\n const res: QueryResponse = await toolkit.github.graphql<QueryResponse>(/* GraphQL */ `\n query getOpenIssues($cursor: String) {\n repository(owner: \"shopware\", name: \"shopware\") {\n issues(first: 100, after: $cursor, states: OPEN) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n number\n title\n labels(first: 20) {\n nodes {\n name\n }\n }\n }\n }\n }\n }\n `, {\n cursor: cursorIssues\n });\n\n const issues = res.repository.issues?.nodes ?? [];\n hasNextPageIssues = res.repository.issues?.pageInfo.hasNextPage ?? false;\n cursorIssues = res.repository.issues?.pageInfo.endCursor ?? null;\n\n for (const item of issues) {\n await manageNeedsTriageLabel(toolkit, item, needsTriageLabel, dryRun);\n }\n\n processedItems += issues.length;\n toolkit.core.info(`Processed ${processedItems} items so far...`);\n }\n\n // Process pull requests\n let hasNextPagePRs = true;\n let cursorPRs: string | null = null;\n\n while (hasNextPagePRs) {\n const res: QueryResponse = await toolkit.github.graphql<QueryResponse>(/* GraphQL */ `\n query getOpenPRs($cursor: String) {\n repository(owner: \"shopware\", name: \"shopware\") {\n pullRequests(first: 100, after: $cursor, states: OPEN) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n number\n title\n labels(first: 20) {\n nodes {\n name\n }\n }\n }\n }\n }\n }\n `, {\n cursor: cursorPRs\n });\n\n const pullRequests = res.repository.pullRequests?.nodes ?? [];\n hasNextPagePRs = res.repository.pullRequests?.pageInfo.hasNextPage ?? false;\n cursorPRs = res.repository.pullRequests?.pageInfo.endCursor ?? null;\n\n for (const item of pullRequests) {\n await manageNeedsTriageLabel(toolkit, item, needsTriageLabel, dryRun);\n }\n\n processedItems += pullRequests.length;\n toolkit.core.info(`Processed ${processedItems} items so far...`);\n }\n\n toolkit.core.info(`Finished processing ${processedItems} items`);\n}\n\nexport async function findWithProjectItems(toolkit: Toolkit) {\n if (toolkit.context.payload.issue) {\n return await findIssueWithProjectItems(toolkit, toolkit.context.payload.issue.number);\n } else if (toolkit.context.payload.pull_request) {\n return await findPRWithProjectItems(toolkit, toolkit.context.payload.pull_request.number);\n } else {\n throw new Error('only issue and pull_request events are supported');\n }\n}\n\nexport async function linkClosingPR(toolkit: Toolkit, issueNumber: number, prReadToken: string, visibilityFilter: string = \"PRIVATE\", org: string = \"shopware\", repo: string = \"shopware\") {\n const prReadClient = getOctokit(prReadToken);\n type ClosingPRResponse = {\n repository: {\n issue: {\n