@shopware-ag/gh-project-automation
Version:
Project automation for shopware
1 lines • 89.2 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/api/github.ts","../src/api/jira.ts","../src/api/slack.ts","../src/util/dry_run.ts","../src/services/branch.ts","../src/services/issue.ts","../src/services/jira.ts","../src/services/project.ts","../src/services/pull_request.ts","../src/services/slack.ts"],"sourcesContent":["import {GitHubComment, GitHubIssue, 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 * 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) {\n const res = await toolkit.github.graphql<{\n user: {\n organizationVerifiedDomainEmails: string[]\n }\n }>(/* 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\n return res.user.organizationVerifiedDomainEmails;\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 {WebClient} from '@slack/web-api';\n\nexport class SlackClient {\n private webClient: WebClient;\n\n constructor(token: string) {\n this.webClient = new WebClient(token);\n }\n\n async getUserByEmail(email: string): Promise<string | null> {\n const response = await this.webClient.users.lookupByEmail({email});\n\n return response.user?.id || 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 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","import { Toolkit } from \"../types\";\nimport { isDryRun } from \"../util/dry_run\";\n\nexport async function getOldBranches(toolkit: Toolkit, repo: string, excludeRegex: string = \"\", organization: string = \"shopware\"): Promise<string[] | null> {\n const DAYS_UNTIL_STALE = 6 * 30;\n\n const regex = new RegExp(excludeRegex);\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 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}`);\n }\n }\n\n return oldBranches;\n}\n\nexport async function cleanupBranches(toolkit: Toolkit, repo: string, organization: string = \"shopware\") {\n const branches = await getOldBranches(toolkit, repo, organization);\n if (!branches) {\n toolkit.core.error(\"No old branches found!\");\n return;\n }\n for (const branch of branches) {\n if (isDryRun()) {\n toolkit.core.info(`Would delete ${branch}`);\n continue;\n }\n toolkit.core.info(`Deleting ${branch}...`);\n toolkit.github.rest.git.deleteRef({\n owner: organization,\n repo: repo,\n ref: 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\";\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","import {GitHubIssue, JiraIssue, Toolkit} from \"../types\";\nimport {callJiraApi, jiraHost} from \"../api/jira\";\n\n/**\n * createDocumentationTask creates a documentation task in JIRA for a given GitHub issue.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param issue - The issue to create a documentation task for.\n * @param documentationProjectId - The ID of the documentation project.\n * @param description - The description of the documentation task.\n */\nexport async function createDocumentationTask(\n toolkit: Toolkit,\n issue: GitHubIssue,\n documentationProjectId: number | null = 11806,\n description: string | null = null\n): Promise<JiraIssue> {\n const requestBody = buildDocumentationTaskRequest(issue, documentationProjectId, description);\n const docTask = await callJiraApi(toolkit, '/issue', requestBody);\n\n if (!docTask || !docTask.key) {\n throw new Error(`Failed to create documentation task: ${JSON.stringify(docTask)}`);\n }\n\n toolkit.core.info(`Created documentation task in JIRA: https://${jiraHost}/browse/${docTask.key}`);\n\n return docTask;\n}\n\n/**\n * Returns the request body for creating a documentation task in JIRA.\n */\nfunction buildDocumentationTaskRequest(issue: GitHubIssue, documentationProjectId: number | null, description: string | null): object {\n return {\n fields: {\n project: {\n id: documentationProjectId\n },\n summary: `${issue.title}`,\n description: buildDocumentationDescription(issue, description),\n issuetype: {\n name: \"Task\",\n },\n }\n };\n}\n\n/**\n * Returns the description for the documentation task using JIRA's document format.\n */\nfunction buildDocumentationDescription(issue: GitHubIssue, description: string | null): object {\n return {\n version: 1,\n type: \"doc\",\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text: description ?? `This issue was created automatically.`,\n },\n {\n type: \"text\",\n text: `\\n\\nPlease refer to the following links and/or related issues for more information:`,\n },\n ],\n },\n {\n type: \"bulletList\",\n content: [\n {\n type: \"listItem\",\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text: issue.url,\n marks: [\n {\n type: \"link\",\n attrs: {\n href: issue.url,\n },\n },\n ],\n },\n ],\n },\n ],\n }\n ],\n }\n ],\n };\n}\n","import { Label, Toolkit } from \"../types\";\n\nimport {\n addLabelToLabelable,\n addProjectItem,\n closeIssue,\n findIssueWithProjectItems,\n getLabelByName,\n getProjectIdByNumber,\n getProjectInfo,\n setFieldValue\n} from \"../api\";\n\nimport {\n createDocIssueComment, findWithProjectItems,\n getEpicsInProgressByProject,\n hasDocIssueComment\n} from \"./issue\";\n\nimport { createDocumentationTask } from \"../index\";\n\n/**\n * Sets the status of issues in projects using the provided toolkit.\n *\n * @param toolkit - The toolkit instance to interact with the project management system.\n * @param props - The properties for setting the status.\n * @param props.toStatus - The status to set the issues to.\n * @param props.fromStatus - (Optional) The status to filter issues by before setting the new status. Can be a string which has to match exactly or an instance of RegExp\n *\n * @remarks\n * This function finds issues in projects and updates their status based on the provided `toStatus`.\n * If `fromStatus` is provided, only issues with the matching status will be updated.\n * If the `toStatus` option is not found in the project's status options, the issue will be skipped.\n *\n * @example\n * ```typescript\n * await setStatusInProjects({ github, context, core, exec, glob, io, fetch }, { toStatus: 'In Progress', fromStatus: 'Done' });\n * ```\n */\nexport async function setStatusInProjects(toolkit: Toolkit, props: {\n toStatus: string,\n fromStatus?: string | RegExp\n}) {\n const issue = await findWithProjectItems(toolkit);\n\n for (const i in issue.projectItems) {\n const item = issue.projectItems[i];\n const projectInfo = await getProjectInfo(toolkit, { number: item.project.number })\n const statusField = projectInfo.fields.find(field => field.name == \"Status\");\n const toStatusOption = statusField?.options.find(x => x.name.toLowerCase() === props.toStatus.toLowerCase())\n\n if (!toStatusOption) {\n toolkit.core.debug(`Option \"${props.toStatus}\" not found in project ${item.project.number}`)\n continue;\n }\n\n if (props.fromStatus instanceof RegExp) {\n if (!item.fieldValueByName.name.match(props.fromStatus)) {\n toolkit.core.debug(`skipping: issue/pr ${issue.number} status ${item.fieldValueByName} did not match ${props.fromStatus} in project ${item.project.number}`);\n continue;\n }\n } else if (props.fromStatus && item.fieldValueByName.name.toLowerCase() !== props.fromStatus.toLowerCase()) {\n toolkit.core.debug(`skipping: issue/pr ${issue.number} status != ${props.fromStatus} in project ${item.project.number}`)\n continue;\n }\n\n toolkit.core.info(`get item in project ${item.project.number} for issue/pr ${issue.number}`)\n const itemId = (await addProjectItem(toolkit, {\n projectId: projectInfo.node_id,\n issueId: issue.node_id\n })).node_id\n\n await setFieldValue(toolkit, {\n projectId: projectInfo.node_id,\n itemId,\n fieldId: statusField!.id,\n valueId: toStatusOption.id\n })\n }\n}\n\nexport async function syncPriorities(toolkit: Toolkit, excludeList: number[] = []) {\n const issue = toolkit.context.payload.issue!;\n toolkit.core.debug(`Issue node ID: ${issue.node_id}`);\n const priorityLabel = issue.labels.find((label: Label) =>\n label.name.startsWith(\"priority/\")\n )?.name;\n\n if (!priorityLabel) {\n return;\n }\n toolkit.core.info(`Found priority label: ${priorityLabel}`);\n\n const priority = priorityLabel.split('/')[1];\n toolkit.core.info(`Priority: ${priority}`);\n\n const issueWithProjectItems = await findIssueWithProjectItems(toolkit, issue.number);\n for (const projectItem of issueWithProjectItems.projectItems) {\n if (excludeList.includes(projectItem.project.number)) {\n toolkit.core.info(`The project number ${projectItem.project.number} is on the excludeList. skipping...`);\n continue;\n }\n const projectInfo = await getProjectInfo(toolkit, { number: projectItem.project.number });\n const priorityField = projectInfo.fields.find(field => field.name == \"Priority\");\n if (!priorityField) {\n toolkit.core.info(`${projectInfo.title} doesn't have a priority field. skipping...`);\n continue;\n }\n const priorityOption = priorityField?.options.find(option => option.name == priority);\n if (!priorityOption) {\n toolkit.core.info(`${projectInfo.title} doesn't have the priority option ${priority}. skipping...`);\n continue;\n }\n\n toolkit.core.info(`Setting priority for issue ${issue.number} on ${projectInfo.title} to ${priority}`);\n\n await setFieldValue(toolkit, {\n projectId: projectInfo.node_id,\n itemId: projectItem.id,\n fieldId: priorityField!.id,\n valueId: priorityOption.id\n });\n }\n}\n\n/**\n * createDocumentationTasksForProjects creates documentation tasks for all projects with the given project numbers.\n *\n * @param toolkit - Octokit instance. See: https://octokit.github.io/rest.js\n * @param projectNumbers - The project numbers to create documentation tasks for.\n * @param organization - The organization name whose projects to consider.\n * @param documentationProjectId - The ID of the documentation project.\n * @param description - Prefix for the description of the documentation task.\n * @param comment - Prefix for the documentation task reference comment.\n */\nexport async function createDocumentationTasksForProjects(toolkit: Toolkit, projectNumbers: number[], organization: string | null = \"shopware\", documentationProjectId: number | null = 11806, description: string | null = null, comment: string | null = null) {\n for (const projectNumber of projectNumbers) {\n const projectId = await getProjectIdByNumber(toolkit, projectNumber, organiza