From 7099c34655adbe5b4227cde52ff23469872682ab Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Thu, 10 Jul 2025 15:15:00 +0200 Subject: [PATCH 1/6] script to export all GH issues --- src/modules/fetch_project_list/index.ts | 361 ++++++++++++++++++++++++ src/modules/fetch_project_list/types.ts | 78 +++++ src/shared/graphql_queries.ts | 122 +++++++- 3 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 src/modules/fetch_project_list/index.ts create mode 100644 src/modules/fetch_project_list/types.ts diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts new file mode 100644 index 0000000..39bfddd --- /dev/null +++ b/src/modules/fetch_project_list/index.ts @@ -0,0 +1,361 @@ +import {ProbotOctokit} from "probot"; +import {getProjectItemsForExport} from "../../shared/graphql_queries"; +import {logger} from "../../shared/logger"; +import fs from "fs"; +import {FieldValue, ProjectItem, SearchResult, Issue} from "./types"; + +// export const fetch_all = async (octokit: ProbotOctokit) => { +// console.time('backfill_created_updated_deleted') +// let graphqlCounter = 0; +// let backfilledItems = 0; +// for (let project of config) { +// if (!project) { +// break; +// } +// await fetch_project_list(project.projectNumber, octokit) +// } +// +// console.timeEnd('backfill_created_updated_deleted') +// } + +const searchCutOffArr = [ + // `created:>2025-07-01`, // test set + + `created:>2025-04-01`, + `created:2025-01-01..2025-04-01`, + `created:2024-07-01..2025-01-01`, + 'created:2022-06-01..2024-07-01', + `created:<2022-06-01`, +] + +const repoWhitelist = [ + "cloud", + "neon", + "company_projects", + "analytics", + "neonctl", + "autoscaling" +]; + +const projectsBlacklist = [ + '(TEST) All Company Epics', + '[DEPRECATED] Engineering Tasks', + 'Incident Follow-ups', // to be migrated as labels? + + + // personal projects, not to be migrated? + '@MihaiBojin', + '@NanoBjorn\' backlog' +] + +// const statusesWhiteList = [ +// 'selected', +// 'in progress', +// 'needs refinement', +// 'triage', +// 'blocked', +// 'inbox' +// ] + +const statusesWhiteListRegex = /selected|in\sprogress|needs\srefinement|triage|blocked|inbox/i + +const getFieldValue = (field: FieldValue) => (field.text || field.date || field.number || field.name || ''); + +const tryGetFieldValue = (search: string, fieldsData: ProjectItem["fieldValues"]["nodes"]) => { + const field = fieldsData.find(fieldItem => fieldItem.field && fieldItem.field.name.toLowerCase().includes(search.toLowerCase())); + + return field ? getFieldValue(field) : ''; +} + +const getIssueId = (issue: Pick) => { + return `${issue.repository.name}#${issue.number}`; +} + +const getParentId = (issue: Issue) => { + if (issue.trackedInIssues && issue.trackedInIssues.totalCount > 1) { + throw new Error('More than one tracked in') + } + const parent = issue.parent; + const trackedIn = issue.trackedInIssues.nodes[0]; + + if (parent && trackedIn) { + throw new Error('Has both parent and tracked in') + } + + return (parent && getIssueId(parent)) || (trackedIn && getIssueId(trackedIn)) || ''; +} + +const prepareLabels = (issue: Issue) => { + return issue.labels.nodes + .filter((labelNode) => labelNode.name && !labelNode.name.startsWith("c/") && !labelNode.name.startsWith("team/")) + .map((labelNode) => labelNode.name); +} + +// get project item that is most likely to make progress +const getMainProject = (projectItems: ProjectItem[]) => { + return projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'in progress')) + || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'selected')) + || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'needs refinement')) + || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'blocked')) + || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'inbox')) + || projectItems[0] +} + +const escape = (str: string) => `"${str}"`; + +const MAX_LABELS = 11; +const MAX_TEAMS = 5; +const MAX_COMPONENTS = 5; +const MAX_ASSIGNEES = 1; + +export const fetch_project_list = async (octokit: ProbotOctokit) => { + console.log("start fetching"); + + const stats = { + maxLabelsLength: 0, + maxComponentsLength: 0, + maxProjectsItemsLength: 0, + maxTeams: 0, + maxAssignees: 0, + } + + const exportData = [ + [ + "url", + "id", + "title", + "body", + "issueType", + "author", + "parent", + ...new Array(MAX_ASSIGNEES).fill('assignee'), + ...new Array(MAX_COMPONENTS).fill('component'), + ...new Array(MAX_TEAMS).fill('team'), + ...new Array(MAX_LABELS).fill('label'), + ...new Array(MAX_TEAMS).fill('Statuses in teams projects'), + "Selected project Name", + "Selected status", + "Selected priority", + "Selected size", + ] + ]; + const exportExceptions = [ + [ + "url", + "title", + "reason" + ] + ] + + console.time('fetch_project_list') + let i = 0; + for (let searchSlice of searchCutOffArr) { + const searchQuery = `org:neondatabase is:issue state:open ${searchSlice}`; + let pageInfo: { + hasNextPage: boolean, + endCursor: string, + } = { + hasNextPage: true, + endCursor: '', + } + while (pageInfo.hasNextPage) { + console.log("counter", i); + try { + const res: { search: SearchResult, totalCount: number } = await octokit.graphql(getProjectItemsForExport, { + q: searchQuery, + cursor: pageInfo.endCursor, + }); + logger('info', `Search query: ${searchQuery}, total count: ${res.search.issueCount}`); + const { search } = res; + // logger('info', `processing page from cursor ${pageInfo.endCursor}, itemsCount: ${search.nodes.length}`); + + + pageInfo.endCursor = search.pageInfo.endCursor + pageInfo.hasNextPage = search.pageInfo.hasNextPage + if (res.totalCount > 1000) { + logger('error', `Search slice returned more than 1000 results, query: "${searchQuery}"`); + } + + for (let issue of search.nodes) { + i++; + // logger('info', `it. #${i} Processing issue ${issue.repository.name}#${issue.number} ${issue.title}`) + if (!(repoWhitelist).includes(issue.repository.name)) { + exportExceptions.push([ + issue.url, + issue.title, + 'Does not belong to repositories from the list: "cloud", "neon", "company_projects", "analytics", "neonctl", "autoscaling"' + ]) + continue; + } + + if (!issue.projectItems.nodes.length) { + exportExceptions.push([ + issue.url, + issue.title, + 'Does not belong to any projects' + ]); + continue; + } + + // list of projects which fields get to issue body + const projectItems = issue.projectItems.nodes + .filter((prItem) => ( + !prItem.project.closedAt // don't care about closed projects + && !prItem.isArchived // don't care about archived items + )) + + if (!projectItems.length) { + exportExceptions.push([ + issue.url, + issue.title, + 'Does not belong to any open projects' + ]); + continue; + } + + // sanitize projectItems + let activeProjectItems = projectItems + .filter((prItem) => ( + !projectsBlacklist.includes(prItem.project.title) // skip certain projects + )) + + stats.maxProjectsItemsLength = Math.max(activeProjectItems.length, stats.maxProjectsItemsLength) + + if (!activeProjectItems.length) { + exportExceptions.push([ + issue.url, + issue.title, + 'All the projects the issue belongs to are blacklisted' + ]); + continue; + } + + activeProjectItems = activeProjectItems + .filter((prItem) => { + const status = tryGetFieldValue('status', prItem.fieldValues.nodes) + + return status && status.match(statusesWhiteListRegex) // only allow certain statuses + }) + + if (!activeProjectItems.length) { + exportExceptions.push([ + issue.url, + issue.title, + 'No projects with whitelisted statuses' + ]); + continue; + } + + // get project item with the best status (In Progress, Selected, Blocked, Need Refinement, Inbox) + const mainProjectItem = getMainProject(activeProjectItems); + + // get components from labels + const components = issue.labels.nodes + .filter((labelNode) => labelNode.name && labelNode.name.startsWith("c/")) + .map((labelNode) => labelNode.name.substring(2)); + stats.maxComponentsLength = Math.max(components.length, stats.maxComponentsLength) + components.length = MAX_COMPONENTS + + // sanitise labels + const labels = prepareLabels(issue); + stats.maxLabelsLength = Math.max(labels.length, stats.maxLabelsLength) + labels.length = MAX_LABELS + + const teams = activeProjectItems.map((prItem) => (prItem.project.title)); + stats.maxTeams = Math.max(teams.length, stats.maxTeams); + teams.length = MAX_TEAMS + + const assignees = issue.assignees.nodes.map((item) => (item.login)); + stats.maxAssignees = Math.max(assignees.length, stats.maxAssignees); + assignees.length = MAX_ASSIGNEES; + + const teamsStatuses = activeProjectItems.map((prItem) => (`${prItem.project.title}/${tryGetFieldValue('status', prItem.fieldValues.nodes) || 'unknown'}`)); + teamsStatuses.length = MAX_TEAMS; + + // const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { + // let res = `||Field||Value||\n`; + // res = res + prItem.fieldValues.nodes + // .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") + // .map((fieldValue: any) => ( + // `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` + // )).join('\n') + // + // return `${prItem.project.title}\n${res}`; + // }).join('\n\n') + + const id = getIssueId(issue); + // const body = escape(`[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}`); + const title = escape(`${issue.title}, ${id}`); + const author = issue.author.login // todo: map to databricks emails pending Crystal + let parent = ''; + + try { + parent = getParentId(issue); + } catch (e: any) { + exportExceptions.push([issue.url, issue.title, e]); + continue; + } + + exportData.push( + [ + // "url", + issue.url, + // "repo#number", + id, + // "title", + title, + // "body", + '', + // "issueType", + issue.issueType ? issue.issueType.name : 'Task', + // "author", + author, + // "parent", + parent, + // assignee + ...assignees, + // ...new Array(5).fill('component'), + ...components, + // ...new Array(5).fill('team'), + ...teams, + // ...new Array(5).fill('label'), + ...labels, + // ...new Array(MAX_TEAMS).fill('Statuses in teams projects'), + ...teamsStatuses, + // "Selected project Name", + mainProjectItem.project.title, + // "Selected status", + tryGetFieldValue("status", mainProjectItem.fieldValues.nodes), + // "Selected priority", + tryGetFieldValue("priority", mainProjectItem.fieldValues.nodes), + // "Selected size", + tryGetFieldValue("size", mainProjectItem.fieldValues.nodes), + ] + ) + } + + logger('info', search); + } catch (e) { + logger('error', e) + } + } + } + const content = exportData.map(item => item.join('ΓΏ')).join('\n') + const exceptionsContent = exportExceptions.map(item => item.join('ΓΏ')).join('\n') + + console.log("stats") + console.log(stats) + console.log("Processed issues:", i); + console.log(`will migrate: ${exportData.length - 1}, will not migrate: ${exportExceptions.length - 1}`); + console.timeEnd('fetch_project_list') + + try { + const timestamp = +new Date(); + fs.writeFileSync(`./${timestamp}_Dump_all.csv`, content); + fs.writeFileSync(`./${timestamp}_Exceptions_all.csv`, exceptionsContent); + console.log("written") + // file written successfully + } catch (err) { + console.error(err); + } +} \ No newline at end of file diff --git a/src/modules/fetch_project_list/types.ts b/src/modules/fetch_project_list/types.ts new file mode 100644 index 0000000..f31ad28 --- /dev/null +++ b/src/modules/fetch_project_list/types.ts @@ -0,0 +1,78 @@ +export type PageInfo = { + endCursor: string, + hasNextPage: boolean, + startCursor: string, + hasPreviousPage: boolean, +} + +type Repository = { + name: string +} + +export type FieldValue = { + field: { + id: string + name: string + } + text?: string + date?: string + name?: string + number?: string +} & ({text: string} | {date: string} | { name: string } |{number: number}) + +export type ProjectItem = { + id: string + isArchived: boolean + type: string + updatedAt: string + project: { + id: string + title: string + closedAt: string + } + fieldValues: { + nodes: Array + } +} + +export type Issue = { + number: number + title: string + body: string + url: string + createdAt: string + updatedAt: string + closedAt: string + repository: Repository + issueType: { + name: string + }, + author: { + login: string + } + trackedInIssues: { + totalCount: number + nodes: Array> + } + parent: Pick + assignees: { + totalCount: number + nodes: Array<{ + login: string + }> + } + labels: { + nodes: Array<{ + name: string + }> + } + projectItems: { + nodes: Array + } +} + +export type SearchResult = { + pageInfo: PageInfo, + issueCount: number, + nodes: Array +} diff --git a/src/shared/graphql_queries.ts b/src/shared/graphql_queries.ts index cc9a3ba..86bb534 100644 --- a/src/shared/graphql_queries.ts +++ b/src/shared/graphql_queries.ts @@ -419,4 +419,124 @@ query($id: ID!){ } } } -` \ No newline at end of file +` + +export const getProjectItemsForExport = ` +query ($q: String!, $cursor: String!){ + search(query: $q, type: ISSUE, first: 100, after: $cursor){ + pageInfo { + endCursor, + hasNextPage, + startCursor, + hasPreviousPage, + } + issueCount + nodes { + ... on Issue { + number, + title, + body, + url, + createdAt, + updatedAt, + closedAt, + issueType { + ... on IssueType { + name + } + } + trackedInIssues(last: 5) { + totalCount + nodes { + ... on Issue { + repository { + ... on Repository { + name + } + } + number + } + } + } + parent { + ... on Issue { + number + repository { + ... on Repository { + name + } + } + } + } + repository { + ... on Repository { + name + } + } + author { + ... on Actor { + login + } + } + assignees (first: 10) { + totalCount + nodes { + ... on User { + login + } + } + } + labels(first: 20) { + nodes { + ... on Label { + name + } + } + } + projectItems(first: 10, includeArchived: false) { + ... on ProjectV2ItemConnection { + nodes{ + ... on ProjectV2Item { + id, + isArchived, + type, + updatedAt, + project { + ... on ProjectV2 { + id + title + closedAt + } + } + fieldValues(first: 15) { + nodes { + ... on ProjectV2ItemFieldValueCommon { + field { + ... on ProjectV2FieldCommon { + id + name + } + } + } + ... on ProjectV2ItemFieldDateValue { + date + } + ... on ProjectV2ItemFieldSingleSelectValue { + name + } + ... on ProjectV2ItemFieldNumberValue { + number + } + ... on ProjectV2ItemFieldTextValue { + text + } + } + }, + } + } + } + } + } + } + } +}` \ No newline at end of file From 87174aa47aa94e6f051507a5989659cdba6f4d13 Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Thu, 10 Jul 2025 20:34:49 +0200 Subject: [PATCH 2/6] cleanup --- src/modules/fetch_project_list/index.ts | 93 ++++++++++--------------- 1 file changed, 37 insertions(+), 56 deletions(-) diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts index 39bfddd..a703800 100644 --- a/src/modules/fetch_project_list/index.ts +++ b/src/modules/fetch_project_list/index.ts @@ -4,20 +4,6 @@ import {logger} from "../../shared/logger"; import fs from "fs"; import {FieldValue, ProjectItem, SearchResult, Issue} from "./types"; -// export const fetch_all = async (octokit: ProbotOctokit) => { -// console.time('backfill_created_updated_deleted') -// let graphqlCounter = 0; -// let backfilledItems = 0; -// for (let project of config) { -// if (!project) { -// break; -// } -// await fetch_project_list(project.projectNumber, octokit) -// } -// -// console.timeEnd('backfill_created_updated_deleted') -// } - const searchCutOffArr = [ // `created:>2025-07-01`, // test set @@ -91,6 +77,27 @@ const prepareLabels = (issue: Issue) => { .map((labelNode) => labelNode.name); } +const prepareBody = (issue: Issue, projectItems: ProjectItem[])=> { + const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { + let res = `||Field||Value||\n`; + res = res + prItem.fieldValues.nodes + .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") + .map((fieldValue: any) => ( + `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` + )).join('\n') + + return `${prItem.project.title}\n${res}`; + }).join('\n\n') + + return escape(`[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}`); +} + +const prepareComponents = (issue:Issue) => { + return issue.labels.nodes + .filter((labelNode) => labelNode.name && labelNode.name.startsWith("c/")) + .map((labelNode) => labelNode.name.substring(2)); +} + // get project item that is most likely to make progress const getMainProject = (projectItems: ProjectItem[]) => { return projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'in progress')) @@ -147,6 +154,14 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { ] ] + const addException = (issue: Issue, reason: string) => { + exportExceptions.push([ + issue.url, + issue.title, + reason + ]); + } + console.time('fetch_project_list') let i = 0; for (let searchSlice of searchCutOffArr) { @@ -180,20 +195,12 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { i++; // logger('info', `it. #${i} Processing issue ${issue.repository.name}#${issue.number} ${issue.title}`) if (!(repoWhitelist).includes(issue.repository.name)) { - exportExceptions.push([ - issue.url, - issue.title, - 'Does not belong to repositories from the list: "cloud", "neon", "company_projects", "analytics", "neonctl", "autoscaling"' - ]) + addException(issue, 'Does not belong to whitelisted repositories') continue; } if (!issue.projectItems.nodes.length) { - exportExceptions.push([ - issue.url, - issue.title, - 'Does not belong to any projects' - ]); + addException(issue, 'Does not belong to any projects') continue; } @@ -205,11 +212,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { )) if (!projectItems.length) { - exportExceptions.push([ - issue.url, - issue.title, - 'Does not belong to any open projects' - ]); + addException(issue, 'Does not belong to any open projects') continue; } @@ -222,27 +225,18 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { stats.maxProjectsItemsLength = Math.max(activeProjectItems.length, stats.maxProjectsItemsLength) if (!activeProjectItems.length) { - exportExceptions.push([ - issue.url, - issue.title, - 'All the projects the issue belongs to are blacklisted' - ]); + addException(issue, 'All the projects the issue belongs to are blacklisted') continue; } activeProjectItems = activeProjectItems .filter((prItem) => { const status = tryGetFieldValue('status', prItem.fieldValues.nodes) - return status && status.match(statusesWhiteListRegex) // only allow certain statuses }) if (!activeProjectItems.length) { - exportExceptions.push([ - issue.url, - issue.title, - 'No projects with whitelisted statuses' - ]); + addException(issue, 'No projects with whitelisted statuses') continue; } @@ -250,9 +244,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { const mainProjectItem = getMainProject(activeProjectItems); // get components from labels - const components = issue.labels.nodes - .filter((labelNode) => labelNode.name && labelNode.name.startsWith("c/")) - .map((labelNode) => labelNode.name.substring(2)); + const components = prepareComponents(issue); stats.maxComponentsLength = Math.max(components.length, stats.maxComponentsLength) components.length = MAX_COMPONENTS @@ -272,19 +264,8 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { const teamsStatuses = activeProjectItems.map((prItem) => (`${prItem.project.title}/${tryGetFieldValue('status', prItem.fieldValues.nodes) || 'unknown'}`)); teamsStatuses.length = MAX_TEAMS; - // const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { - // let res = `||Field||Value||\n`; - // res = res + prItem.fieldValues.nodes - // .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") - // .map((fieldValue: any) => ( - // `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` - // )).join('\n') - // - // return `${prItem.project.title}\n${res}`; - // }).join('\n\n') - const id = getIssueId(issue); - // const body = escape(`[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}`); + const body = prepareBody(issue, projectItems); const title = escape(`${issue.title}, ${id}`); const author = issue.author.login // todo: map to databricks emails pending Crystal let parent = ''; @@ -305,7 +286,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { // "title", title, // "body", - '', + body, // "issueType", issue.issueType ? issue.issueType.name : 'Task', // "author", From eae8858ef51c77e947e0c4999ee8199bf6861c6a Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Thu, 10 Jul 2025 20:45:29 +0200 Subject: [PATCH 3/6] add prepareIssueType --- src/modules/fetch_project_list/index.ts | 26 ++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts index a703800..586c37f 100644 --- a/src/modules/fetch_project_list/index.ts +++ b/src/modules/fetch_project_list/index.ts @@ -5,13 +5,13 @@ import fs from "fs"; import {FieldValue, ProjectItem, SearchResult, Issue} from "./types"; const searchCutOffArr = [ - // `created:>2025-07-01`, // test set + `created:>2025-07-01`, // test set - `created:>2025-04-01`, - `created:2025-01-01..2025-04-01`, - `created:2024-07-01..2025-01-01`, - 'created:2022-06-01..2024-07-01', - `created:<2022-06-01`, + // `created:>2025-04-01`, + // `created:2025-01-01..2025-04-01`, + // `created:2024-07-01..2025-01-01`, + // 'created:2022-06-01..2024-07-01', + // `created:<2022-06-01`, ] const repoWhitelist = [ @@ -98,6 +98,16 @@ const prepareComponents = (issue:Issue) => { .map((labelNode) => labelNode.name.substring(2)); } +const prepareIssueType = (issue: Issue) => { + // todo + + if (issue.repository.name === "company_projects") { + return "Major Initiative" + } + + return issue.issueType.name || 'Task'; +} + // get project item that is most likely to make progress const getMainProject = (projectItems: ProjectItem[]) => { return projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'in progress')) @@ -277,6 +287,8 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { continue; } + const issueType = prepareIssueType(issue); + exportData.push( [ // "url", @@ -288,7 +300,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { // "body", body, // "issueType", - issue.issueType ? issue.issueType.name : 'Task', + issueType, // "author", author, // "parent", From 6e1b5d70c9b927f90507ea45beb930df5a4f5407 Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Fri, 11 Jul 2025 19:26:33 +0200 Subject: [PATCH 4/6] fix filters --- src/modules/fetch_project_list/index.ts | 194 ++++++++++++++++-------- 1 file changed, 130 insertions(+), 64 deletions(-) diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts index 586c37f..7e23327 100644 --- a/src/modules/fetch_project_list/index.ts +++ b/src/modules/fetch_project_list/index.ts @@ -3,15 +3,19 @@ import {getProjectItemsForExport} from "../../shared/graphql_queries"; import {logger} from "../../shared/logger"; import fs from "fs"; import {FieldValue, ProjectItem, SearchResult, Issue} from "./types"; +// @ts-ignore +// import J2M from 'j2m'; const searchCutOffArr = [ - `created:>2025-07-01`, // test set - - // `created:>2025-04-01`, - // `created:2025-01-01..2025-04-01`, - // `created:2024-07-01..2025-01-01`, - // 'created:2022-06-01..2024-07-01', - // `created:<2022-06-01`, + // `created:>2025-07-01`, // test set + // `31093` // test set + + // prod set + `created:>2025-04-01`, + `created:2025-01-01..2025-04-01`, + `created:2024-07-01..2025-01-01`, + 'created:2022-06-01..2024-07-01', + `created:<2022-06-01`, ] const repoWhitelist = [ @@ -24,14 +28,49 @@ const repoWhitelist = [ ]; const projectsBlacklist = [ - '(TEST) All Company Epics', - '[DEPRECATED] Engineering Tasks', - 'Incident Follow-ups', // to be migrated as labels? + '@erikgrinaker\'s untitled project', + 'CIv2 Project', + 'Deals', + 'PD: AI XP team', + '[TEMPLATE] tasks', + 'Infra Initiatives', + 'Postgres team tasks', + 'PD: API Platform team tasks', + 'PD: QA tasks', + 'SOC2', + 'Triage: Product Delivery', + '@MihaiBojin', + 'Developer Productivity team tasks', + 'PD: Workflow tasks', + 'PD: Growth', + 'PD: Identity tasks', +] +const projectsAsTeams = [ + 'PCI-DSS', + 'Data team tasks', + 'RE: Support Tools', + 'Product Design Team Tasks', + 'PD: FE Infra tasks', + 'PD: Workflow tasks', + 'Pl: Compute team tasks', + 'Pl: Proxy team tasks', + 'PD: DBaaS tasks', + 'Security', + 'PD: Docs tasks', + 'Pl: Storage team tasks', + 'PD: Billing tasks', + 'Pl: Control-plane team tasks', + 'Pl: Autoscaling team tasks' +] - // personal projects, not to be migrated? - '@MihaiBojin', - '@NanoBjorn\' backlog' +const projectsAsLabels = [ + 'COGS', + 'Project Trust', + 'Support Escalations', + 'PD: Azure tasks', + 'HIPAA', + 'Incident Follow-ups' ] // const statusesWhiteList = [ @@ -43,7 +82,7 @@ const projectsBlacklist = [ // 'inbox' // ] -const statusesWhiteListRegex = /selected|in\sprogress|needs\srefinement|triage|blocked|inbox/i +// const statusesWhiteListRegex = /selected|in\sprogress|needs\srefinement|triage|blocked|inbox/i const getFieldValue = (field: FieldValue) => (field.text || field.date || field.number || field.name || ''); @@ -72,24 +111,40 @@ const getParentId = (issue: Issue) => { } const prepareLabels = (issue: Issue) => { - return issue.labels.nodes + const ghLabels = issue.labels.nodes .filter((labelNode) => labelNode.name && !labelNode.name.startsWith("c/") && !labelNode.name.startsWith("team/")) .map((labelNode) => labelNode.name); + const projectToLabels = issue.projectItems.nodes.filter(prItem => ( + !prItem.isArchived && projectsAsLabels.includes(prItem.project.title) + )).map(prItem => `project/${prItem.project.title.replace(/\s/g, '_')}`); + + return [ + ...ghLabels, + ...projectToLabels + ] } const prepareBody = (issue: Issue, projectItems: ProjectItem[])=> { - const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { - let res = `||Field||Value||\n`; - res = res + prItem.fieldValues.nodes - .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") - .map((fieldValue: any) => ( - `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` - )).join('\n') - - return `${prItem.project.title}\n${res}`; - }).join('\n\n') - - return escape(`[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}`); + // todo: replace links + console.log(issue) + console.log(projectItems) + + // const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { + // let res = `||Field||Value||\n`; + // res = res + prItem.fieldValues.nodes + // .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") + // .map((fieldValue: any) => ( + // `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` + // )).join('\n') + // + // return `### ${prItem.project.title}\n${res}`; + // }).join('\n\n') + // + // const content = `[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}` + // + // const wiki = J2M.toJ(content); + // return escape(wiki); + return '' } const prepareComponents = (issue:Issue) => { @@ -105,11 +160,17 @@ const prepareIssueType = (issue: Issue) => { return "Major Initiative" } - return issue.issueType.name || 'Task'; + return issue.issueType ? issue.issueType.name : 'Task'; } // get project item that is most likely to make progress -const getMainProject = (projectItems: ProjectItem[]) => { +const getMainProject = (teamProjectItems: ProjectItem[], labelProjectItems: ProjectItem[]) => { + if (!teamProjectItems.length && !labelProjectItems.length) { + return; + } + + const projectItems = teamProjectItems.length ? teamProjectItems : labelProjectItems; + return projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'in progress')) || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'selected')) || projectItems.find((prItem) => (tryGetFieldValue("status", prItem.fieldValues.nodes).toLowerCase() === 'needs refinement')) @@ -149,7 +210,8 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { ...new Array(MAX_COMPONENTS).fill('component'), ...new Array(MAX_TEAMS).fill('team'), ...new Array(MAX_LABELS).fill('label'), - ...new Array(MAX_TEAMS).fill('Statuses in teams projects'), + ...new Array(MAX_TEAMS).fill('Statuses in team projects'), + ...new Array(MAX_TEAMS).fill('Statuses in label projects'), "Selected project Name", "Selected status", "Selected priority", @@ -194,7 +256,6 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { const { search } = res; // logger('info', `processing page from cursor ${pageInfo.endCursor}, itemsCount: ${search.nodes.length}`); - pageInfo.endCursor = search.pageInfo.endCursor pageInfo.hasNextPage = search.pageInfo.hasNextPage if (res.totalCount > 1000) { @@ -205,7 +266,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { i++; // logger('info', `it. #${i} Processing issue ${issue.repository.name}#${issue.number} ${issue.title}`) if (!(repoWhitelist).includes(issue.repository.name)) { - addException(issue, 'Does not belong to whitelisted repositories') + addException(issue, `Does not belong to whitelisted repositories, repo name: ${issue.repository.name}`) continue; } @@ -215,43 +276,44 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { } // list of projects which fields get to issue body - const projectItems = issue.projectItems.nodes + const whitelistedProjectItems = issue.projectItems.nodes .filter((prItem) => ( !prItem.project.closedAt // don't care about closed projects && !prItem.isArchived // don't care about archived items + && !projectsBlacklist.includes(prItem.project.title) // exclude blacklisted projects + // && // todo: exclude blacklisted statuses )) - if (!projectItems.length) { - addException(issue, 'Does not belong to any open projects') + if (!whitelistedProjectItems.length) { + const projectsStr = issue.projectItems.nodes + .map((prItem) => { + if (prItem.project.closedAt) { + `${prItem.project.title}: project closed at ${prItem.project.closedAt}` + } + if (prItem.isArchived) { + return `${prItem.project.title}: issue archived` + } + if (projectsBlacklist.includes(prItem.project.title)) { + return `${prItem.project.title} is not whitelisted` + } + return '' + }).filter(Boolean).join('; ') + addException(issue, `Does not belong to any allowed projects, details: ${projectsStr}`) continue; } + stats.maxProjectsItemsLength = Math.max(whitelistedProjectItems.length, stats.maxProjectsItemsLength) - // sanitize projectItems - let activeProjectItems = projectItems + const teamsProjectItems = whitelistedProjectItems .filter((prItem) => ( - !projectsBlacklist.includes(prItem.project.title) // skip certain projects + projectsAsTeams.includes(prItem.project.title) // only for teams we want to migrate as Assigned Team )) - stats.maxProjectsItemsLength = Math.max(activeProjectItems.length, stats.maxProjectsItemsLength) - - if (!activeProjectItems.length) { - addException(issue, 'All the projects the issue belongs to are blacklisted') - continue; - } - - activeProjectItems = activeProjectItems - .filter((prItem) => { - const status = tryGetFieldValue('status', prItem.fieldValues.nodes) - return status && status.match(statusesWhiteListRegex) // only allow certain statuses - }) - - if (!activeProjectItems.length) { - addException(issue, 'No projects with whitelisted statuses') - continue; - } + const labelProjectItems = whitelistedProjectItems.filter((prItem) => ( + projectsAsLabels.includes(prItem.project.title) // only for teams we want to migrate as Assigned Team + )) // get project item with the best status (In Progress, Selected, Blocked, Need Refinement, Inbox) - const mainProjectItem = getMainProject(activeProjectItems); + const mainProjectItem: ProjectItem | undefined = getMainProject(teamsProjectItems, labelProjectItems); // get components from labels const components = prepareComponents(issue); @@ -263,7 +325,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { stats.maxLabelsLength = Math.max(labels.length, stats.maxLabelsLength) labels.length = MAX_LABELS - const teams = activeProjectItems.map((prItem) => (prItem.project.title)); + const teams = teamsProjectItems.map((prItem) => (prItem.project.title)); stats.maxTeams = Math.max(teams.length, stats.maxTeams); teams.length = MAX_TEAMS @@ -271,11 +333,13 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { stats.maxAssignees = Math.max(assignees.length, stats.maxAssignees); assignees.length = MAX_ASSIGNEES; - const teamsStatuses = activeProjectItems.map((prItem) => (`${prItem.project.title}/${tryGetFieldValue('status', prItem.fieldValues.nodes) || 'unknown'}`)); + const teamsStatuses = teamsProjectItems.map((prItem) => (`${prItem.project.title}/${tryGetFieldValue('status', prItem.fieldValues.nodes) || 'unknown'}`)); teamsStatuses.length = MAX_TEAMS; + const labelStatuses = labelProjectItems.map((prItem) => (`${prItem.project.title}/${tryGetFieldValue('status', prItem.fieldValues.nodes) || 'unknown'}`)); + labelStatuses.length = MAX_TEAMS; const id = getIssueId(issue); - const body = prepareBody(issue, projectItems); + const body = prepareBody(issue, whitelistedProjectItems); const title = escape(`${issue.title}, ${id}`); const author = issue.author.login // todo: map to databricks emails pending Crystal let parent = ''; @@ -315,14 +379,16 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { ...labels, // ...new Array(MAX_TEAMS).fill('Statuses in teams projects'), ...teamsStatuses, + // ...new Array(MAX_TEAMS).fill('Statuses in label projects'), + ...labelStatuses, // "Selected project Name", - mainProjectItem.project.title, + mainProjectItem ? mainProjectItem.project.title : '', // "Selected status", - tryGetFieldValue("status", mainProjectItem.fieldValues.nodes), + mainProjectItem ? tryGetFieldValue("status", mainProjectItem.fieldValues.nodes) : '', // "Selected priority", - tryGetFieldValue("priority", mainProjectItem.fieldValues.nodes), + mainProjectItem ? tryGetFieldValue("priority", mainProjectItem.fieldValues.nodes) : '', // "Selected size", - tryGetFieldValue("size", mainProjectItem.fieldValues.nodes), + mainProjectItem ? tryGetFieldValue("size", mainProjectItem.fieldValues.nodes) : '', ] ) } @@ -343,7 +409,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { console.timeEnd('fetch_project_list') try { - const timestamp = +new Date(); + const timestamp = new Date().toISOString(); fs.writeFileSync(`./${timestamp}_Dump_all.csv`, content); fs.writeFileSync(`./${timestamp}_Exceptions_all.csv`, exceptionsContent); console.log("written") From 54c3bde395739ecfdeb724d55f06ee1bf39f84dd Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Fri, 11 Jul 2025 19:47:49 +0200 Subject: [PATCH 5/6] improve error reporting --- src/modules/fetch_project_list/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts index 7e23327..703cd00 100644 --- a/src/modules/fetch_project_list/index.ts +++ b/src/modules/fetch_project_list/index.ts @@ -288,16 +288,16 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { const projectsStr = issue.projectItems.nodes .map((prItem) => { if (prItem.project.closedAt) { - `${prItem.project.title}: project closed at ${prItem.project.closedAt}` + `"${prItem.project.title}": project closed at ${prItem.project.closedAt}` } if (prItem.isArchived) { - return `${prItem.project.title}: issue archived` + return `"${prItem.project.title}": issue archived` } if (projectsBlacklist.includes(prItem.project.title)) { - return `${prItem.project.title} is not whitelisted` + return `"${prItem.project.title}": project is not whitelisted` } - return '' - }).filter(Boolean).join('; ') + return `"${prItem.project.title}": unknown reason, check me` + }).join('; ') addException(issue, `Does not belong to any allowed projects, details: ${projectsStr}`) continue; } From 16637bb33bddcb1e7351fa251cd17a93e35e7058 Mon Sep 17 00:00:00 2001 From: Polina Semenova Date: Mon, 14 Jul 2025 12:10:20 +0200 Subject: [PATCH 6/6] add priority map and links to authors in GH --- package.json | 1 + src/modules/fetch_project_list/index.ts | 100 ++++++++++++++---------- src/modules/fetch_project_list/types.ts | 2 + src/shared/graphql_queries.ts | 2 + yarn.lock | 15 +++- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index fd7d113..d94578c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@slack/web-api": "^6.7.1", "async-await-queue": "^1.2.0", "axios": "^0.26.0", + "j2m": "^1.1.0", "probot": "14.0.0-beta.11" }, "devDependencies": { diff --git a/src/modules/fetch_project_list/index.ts b/src/modules/fetch_project_list/index.ts index 703cd00..c94d975 100644 --- a/src/modules/fetch_project_list/index.ts +++ b/src/modules/fetch_project_list/index.ts @@ -4,7 +4,7 @@ import {logger} from "../../shared/logger"; import fs from "fs"; import {FieldValue, ProjectItem, SearchResult, Issue} from "./types"; // @ts-ignore -// import J2M from 'j2m'; +import J2M from 'j2m'; const searchCutOffArr = [ // `created:>2025-07-01`, // test set @@ -73,6 +73,21 @@ const projectsAsLabels = [ 'Incident Follow-ups' ] +const priorityMap = { + 'P0':'Blocker', + 'P1':'Critical', + 'P2':'Major', + 'P3':'Minor', + 'πŸŒ‹ Urgent':'Critical', + 'πŸ” High':'Major', + 'πŸ• Medium':'Minor', + '🏝 Low':'Trivial', + 'πŸŒ‹ P0 - High':'Blocker', + 'πŸ” P1 - Moderate':'Critical', + 'πŸ• P2 - Low Priority':'Major', + '🏝 P3 - Negligible':'Minor', +} + // const statusesWhiteList = [ // 'selected', // 'in progress', @@ -96,20 +111,6 @@ const getIssueId = (issue: Pick) => { return `${issue.repository.name}#${issue.number}`; } -const getParentId = (issue: Issue) => { - if (issue.trackedInIssues && issue.trackedInIssues.totalCount > 1) { - throw new Error('More than one tracked in') - } - const parent = issue.parent; - const trackedIn = issue.trackedInIssues.nodes[0]; - - if (parent && trackedIn) { - throw new Error('Has both parent and tracked in') - } - - return (parent && getIssueId(parent)) || (trackedIn && getIssueId(trackedIn)) || ''; -} - const prepareLabels = (issue: Issue) => { const ghLabels = issue.labels.nodes .filter((labelNode) => labelNode.name && !labelNode.name.startsWith("c/") && !labelNode.name.startsWith("team/")) @@ -124,27 +125,40 @@ const prepareLabels = (issue: Issue) => { ] } -const prepareBody = (issue: Issue, projectItems: ProjectItem[])=> { +const prepareBody = (issue: Issue, projectItems: ProjectItem[], linksMap: Record)=> { // todo: replace links console.log(issue) console.log(projectItems) - // const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + projectItems.map((prItem: any) => { - // let res = `||Field||Value||\n`; - // res = res + prItem.fieldValues.nodes - // .filter((fieldValue: any) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") - // .map((fieldValue: any) => ( - // `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` - // )).join('\n') - // - // return `### ${prItem.project.title}\n${res}`; - // }).join('\n\n') - // - // const content = `[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt}\n\n${issue.body}\n\n${projectItemsFieldsText}` - // - // const wiki = J2M.toJ(content); - // return escape(wiki); - return '' + const projectItemsFieldsText = "## Fields from Neon Github Projects\n\n" + + projectItems.map((prItem) => { + let res = `||Field||Value||\n`; + res = res + prItem.fieldValues.nodes + .filter((fieldValue) => fieldValue.field && fieldValue.field.name && !fieldValue.field.name.includes('πŸ”„') && fieldValue.field.name !== "Title") + .map((fieldValue) => ( + `|${fieldValue.field.name}|${getFieldValue(fieldValue)}|` + )).join('\n') + + return `### ${prItem.project.title}\n${res}`; + }).join('\n') + + const reporterLink = `[${issue.author.login}](${issue.author.url})`; + const assigneesArr = issue.assignees.nodes.map(user => (`[${user.login}](${user.url})`)) + + const originalAssignees = assigneesArr.length ? `GH Assignees: ${assigneesArr.join(', ')}\n\n` : '\n'; + + // replacing links to look nicer in body + // const mathFullLinks = + + const content = `[Original Github Issue from Neon](${issue.url}), created at ${issue.createdAt} by ${reporterLink}\n` + + originalAssignees + + issue.body + + "\n\n" + + projectItemsFieldsText + + const wiki = J2M.toJ(content); + return escape(wiki); + // return '' } const prepareComponents = (issue:Issue) => { @@ -185,6 +199,7 @@ const MAX_LABELS = 11; const MAX_TEAMS = 5; const MAX_COMPONENTS = 5; const MAX_ASSIGNEES = 1; +const MAX_LINKED = 5; export const fetch_project_list = async (octokit: ProbotOctokit) => { console.log("start fetching"); @@ -195,6 +210,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { maxProjectsItemsLength: 0, maxTeams: 0, maxAssignees: 0, + maxRelatesTo: 0, } const exportData = [ @@ -206,6 +222,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { "issueType", "author", "parent", + ...new Array(MAX_LINKED).fill('relates to'), ...new Array(MAX_ASSIGNEES).fill('assignee'), ...new Array(MAX_COMPONENTS).fill('component'), ...new Array(MAX_TEAMS).fill('team'), @@ -339,17 +356,16 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { labelStatuses.length = MAX_TEAMS; const id = getIssueId(issue); - const body = prepareBody(issue, whitelistedProjectItems); + const body = prepareBody(issue, whitelistedProjectItems, {}); const title = escape(`${issue.title}, ${id}`); const author = issue.author.login // todo: map to databricks emails pending Crystal - let parent = ''; + const parent = issue.parent ? getIssueId(issue.parent) : ''; + const relatesTo = issue.trackedInIssues.nodes.map(item => getIssueId(item)); + stats.maxRelatesTo = Math.max(relatesTo.length, stats.maxRelatesTo); + relatesTo.length = MAX_LINKED; + + const selectedPriority = mainProjectItem ? priorityMap[tryGetFieldValue("priority", mainProjectItem.fieldValues.nodes) || 'unknown'] || 'unknown' : ''; - try { - parent = getParentId(issue); - } catch (e: any) { - exportExceptions.push([issue.url, issue.title, e]); - continue; - } const issueType = prepareIssueType(issue); @@ -369,6 +385,8 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { author, // "parent", parent, + // relates to + ...relatesTo, // assignee ...assignees, // ...new Array(5).fill('component'), @@ -386,7 +404,7 @@ export const fetch_project_list = async (octokit: ProbotOctokit) => { // "Selected status", mainProjectItem ? tryGetFieldValue("status", mainProjectItem.fieldValues.nodes) : '', // "Selected priority", - mainProjectItem ? tryGetFieldValue("priority", mainProjectItem.fieldValues.nodes) : '', + selectedPriority, // "Selected size", mainProjectItem ? tryGetFieldValue("size", mainProjectItem.fieldValues.nodes) : '', ] diff --git a/src/modules/fetch_project_list/types.ts b/src/modules/fetch_project_list/types.ts index f31ad28..6271976 100644 --- a/src/modules/fetch_project_list/types.ts +++ b/src/modules/fetch_project_list/types.ts @@ -49,6 +49,7 @@ export type Issue = { }, author: { login: string + url: string } trackedInIssues: { totalCount: number @@ -59,6 +60,7 @@ export type Issue = { totalCount: number nodes: Array<{ login: string + url: string }> } labels: { diff --git a/src/shared/graphql_queries.ts b/src/shared/graphql_queries.ts index 86bb534..8d69d29 100644 --- a/src/shared/graphql_queries.ts +++ b/src/shared/graphql_queries.ts @@ -476,6 +476,7 @@ query ($q: String!, $cursor: String!){ author { ... on Actor { login + url } } assignees (first: 10) { @@ -483,6 +484,7 @@ query ($q: String!, $cursor: String!){ nodes { ... on User { login + url } } } diff --git a/yarn.lock b/yarn.lock index 52a0b38..e0ca6ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1261,6 +1261,11 @@ colorette@^2.0.7: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colors@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + combined-stream@^1.0.6, combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1791,6 +1796,14 @@ is-stream@^1.1.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= +j2m@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/j2m/-/j2m-1.1.0.tgz#bcd2fe36902eaf012eb9613a39a2a9e14e458df4" + integrity sha512-48wXbhVo5fUsqxAhpEPZIP8HJaHGJGK38XwZCyjbS5MuEdkCR786U4U4Hk5g34e60Kf/jF8y+TphVTHYe/mSWA== + dependencies: + colors "^1.1.2" + minimist "^1.2.0" + joycon@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" @@ -1896,7 +1909,7 @@ mime@^2.4.6: resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== -minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==