github-projects/map-labels/mapLabelsToAttributes.ts (193 lines of code) (raw):
import fs from 'fs';
import path from 'path';
import { Octokit } from '@octokit/rest';
import {
gqlGetFieldOptions,
gqlGetIssuesForProject,
gqlGetProject,
gqlUpdateFieldValue,
IssueNode,
SingleSelectField,
} from '../api/projectsGraphQL';
type MapLabelsToAttributesArgs = {
issueNumber: number[];
projectNumber: number;
owner: string;
mapping: string;
all: boolean;
dryRun: boolean;
githubToken: string;
repo?: string; // optional, filtering by repo is not required, issues are connected to a project
};
/**
* Lists issues in a github project, and updates fields on them based on the mapping file given.
*
* The mapping file should be a JSON file with the following structure:
* {
* "<labelName>": {
* "<fieldName>": "<value>"
* }
* ...
* }
*/
export async function mapLabelsToAttributes(args: MapLabelsToAttributesArgs) {
const { issueNumber: issueNumbers, projectNumber, owner, repo, mapping, all, dryRun, githubToken } = args;
const hasFilter = issueNumbers.length > 0;
// If we're requesting all issues, we should list ~1000 issues to max it out
// if we have a filter, we will also want to search for those issues, so max it out
// if we're running with either of these args, we should be fine with the 50 most recent
const issueCount = hasFilter || all ? 1000 : 50;
const octokit = new Octokit({
auth: githubToken.trim(),
});
if (dryRun) {
console.log('⚠️ Running in dry-run mode. No changes will be made.');
}
console.log(`Loading label mapping file ${mapping}`);
const labelsToFields = loadMapping(mapping);
console.log(`Requesting project ${owner}/${projectNumber} and its issues...`);
const projectAndFields = await gqlGetProject(octokit, { projectNumber, owner });
const issuesInProject = await gqlGetIssuesForProject(
octokit,
{ projectNumber, findIssueNumbers: issueNumbers, owner },
{
issueCount,
},
);
const targetIssues = repo ? filterIssuesByRepo(issuesInProject, repo) : issuesInProject;
if (!targetIssues.length) {
console.error(`Could not find any update target(s) issues for params:`, {
projectNumber,
issueNumbers,
repo,
owner,
});
throw new Error('No target issues found');
} else {
console.log(`Found ${targetIssues.length} target issue(s) for update`);
}
const updateResults = {
success: [] as IssueNode[],
failure: [] as IssueNode[],
skipped: [] as IssueNode[],
projectUrl: projectAndFields.url,
};
for (const issueNode of targetIssues) {
console.log(`Updating issue target: ${issueNode.content.url}...`);
try {
const updatedFields = await adjustSingleItemLabels(octokit, {
issueNode,
owner,
projectNumber,
projectId: projectAndFields.id,
mapping: labelsToFields,
dryRun,
});
if (updatedFields.length) {
console.log(`Updated fields: ${updatedFields.join(', ')}`);
updateResults.success.push(issueNode);
} else {
console.log('No fields updated');
updateResults.skipped.push(issueNode);
}
} catch (error) {
console.error('Error updating issue', error);
updateResults.failure.push(issueNode);
}
}
return updateResults;
}
function loadMapping(mappingFileName: string) {
const pathToMapping = path.join(__dirname, mappingFileName);
const mapping = fs.readFileSync(pathToMapping, 'utf8');
return JSON.parse(mapping);
}
function filterIssuesByRepo(issuesInProject: IssueNode[], repo: string | undefined) {
console.log('Filtering issues by repository: ', repo);
return issuesInProject.filter((issue) => {
return issue.content.repository.name === repo;
});
}
async function adjustSingleItemLabels(
octokit: Octokit,
options: {
owner: string;
issueNode: IssueNode;
projectNumber: number;
projectId: string;
dryRun: boolean;
mapping: Record<string, { [fieldName: string]: string } | null>;
},
) {
const { issueNode, projectNumber, projectId, mapping, owner, dryRun } = options;
const { content: issue, id: itemId } = issueNode;
const labels = issue.labels.nodes;
const updatedFields: string[] = [];
// Get fields for each mappable label
for (const label of labels) {
const fieldUpdate = mapping[label.name];
if (!fieldUpdate) {
continue;
}
const fieldName = Object.keys(fieldUpdate)[0];
const value = fieldUpdate[fieldName];
console.log('Finding option for value', { fieldName, value });
// Get field id
const optionForValue = await getOptionIdForValue(octokit, { projectNumber, fieldName, value, owner });
if (!optionForValue) {
continue;
}
// Check if the field is already set
const existingField = issueNode.fieldValues.nodes.find(
(field) => field.__typename === 'ProjectV2ItemFieldSingleSelectValue' && field.field.name === fieldName,
);
const fieldLookup = await getFieldLookupObj(octokit, { projectNumber, owner });
if (existingField) {
const existingFieldValue = fieldLookup[fieldName]?.options.find((e) => e.id === existingField.optionId);
console.log(
`Field "${fieldName}" is already set to "${existingFieldValue?.name}" (${existingField.optionId}), skipping update`,
);
continue;
}
// update field
console.log(`Updating field "${fieldName}" to "${value}" (${optionForValue.optionId})`);
const updateParams = {
projectId,
itemId,
fieldId: optionForValue.fieldId,
optionId: optionForValue.optionId,
fieldName,
};
if (dryRun) {
console.log('Dry run: skipping update for parameters', updateParams);
} else {
await gqlUpdateFieldValue(octokit, updateParams);
}
updatedFields.push(fieldName);
}
return updatedFields;
}
const getFieldLookupObj = (() => {
let fieldLookup: Record<string, SingleSelectField>;
return async (octokit: Octokit, projectOptions: { projectNumber: number; owner: string }) => {
if (typeof fieldLookup === 'undefined' || Object.keys(fieldLookup).length === 0) {
const fieldOptions = await gqlGetFieldOptions(octokit, projectOptions);
const singleSelectFields = fieldOptions.organization.projectV2.fields.nodes.filter(
(f) => f.__typename === 'ProjectV2SingleSelectField',
);
fieldLookup = singleSelectFields.reduce(
(acc, field) => {
acc[field.name] = field;
return acc;
},
{} as Record<string, SingleSelectField>,
);
console.log('Field lookup populated', fieldLookup);
}
return fieldLookup;
};
})();
async function getOptionIdForValue(
octokit: Octokit,
options: { projectNumber: number; fieldName: string; value: string; owner: string },
) {
const { fieldName, value } = options;
const fieldLookup = await getFieldLookupObj(octokit, options);
const field = fieldLookup[fieldName];
if (!field) {
console.error(`Could not find field "${fieldName}" in project fields`);
return null;
}
const optionId = field.options.find((o) => o.name === value)?.id;
if (!optionId) {
console.warn(`Could not find option for field "${fieldName}" and value "${value}"`, field.options);
return null;
} else {
return {
optionId,
fieldId: field.id,
};
}
}