github-projects/api/projectsGraphQL.ts (261 lines of code) (raw):
// Using reference: https://docs.github.com/en/graphql/reference/objects
import { Octokit } from '@octokit/rest';
const MAX_BATCH_SIZE = 100;
type GraphQLNodes<T> = {
pageInfo?: {
endCursor: string;
hasNextPage: boolean;
};
nodes: T[];
};
export type ProjectWithFieldsResponse = {
organization: {
projectV2: {
id: string;
url: string;
fields: GraphQLNodes<{
__typename: string;
name: string;
}>;
};
};
};
export type FieldUpdateResult = {
clientMutationId: string;
projectV2Item: {
id: string;
fieldValueByName: {
name: string;
optionId: string;
};
};
};
export type IssueNode = {
__typename: string;
id: string;
fullDatabaseId: number;
fieldValues: GraphQLNodes<{
__typename: string;
field: {
name: string;
};
name: string;
optionId: string;
}>;
content: {
__typename: string;
id: string;
number: number;
title: string;
url: string;
resourcePath: string;
repository: {
name: string;
owner: {
id: string;
};
};
labels: GraphQLNodes<{
name: string;
}>;
};
};
export type ProjectIssuesResponse = {
organization: {
projectV2: {
items: GraphQLNodes<IssueNode>;
};
};
};
export type SingleSelectField = { id: string; name: string; options: { id: string; name: string }[] };
export type FieldOptions = {
organization: {
projectV2: {
fields: {
nodes: Array<
SingleSelectField & {
__typename: string;
}
>;
};
};
};
};
export type UpdateFieldParams = {
projectId: string;
fieldId: string;
itemId: string;
optionId: string;
fieldName: string;
};
export const gqlGetProject = async (
octokit: Octokit,
{ projectNumber, owner }: { projectNumber: number; owner: string },
) => {
const query = `query{
organization(login: "${owner}"){
projectV2(number: ${projectNumber}){
id
url
title
}
}
}
`;
return (await octokit.graphql<ProjectWithFieldsResponse>(query)).organization.projectV2;
};
/**
* Fetches issues for a project.
* In Github's graphql API, the projectV2 field has items in it, but not all of those are issues,
* so we have to paginate through, and collect issues to satisfy the issueCount.
* Also, the issues listing doesn't have any filtering, so we have to get pages, and filter them ourselves.
*
* @param octokit - The Octokit instance.
* @param projectNumber - The project number. (e.g.: https://github.com/<owner>/<repo>/projects/<projectNumber>)
* @param findIssueNumbers - An array of issue numbers to find.
* @param owner - The owner of the repository.
* @param limitOptions - Optional limit options for pagination.
* @returns An array of issues.
*/
export const gqlGetIssuesForProject = async (
octokit: Octokit,
{
projectNumber,
findIssueNumbers = [],
owner,
}: { projectNumber: number; findIssueNumbers?: number[]; owner: string },
limitOptions?: {
issueCount?: number;
issueFieldCount?: number;
labelsCount?: number;
},
) => {
const { issueCount = 20, issueFieldCount = 10, labelsCount = 10 } = limitOptions || {};
const findIssueNumbersSet = new Set(findIssueNumbers);
console.log(`Fetching ${findIssueNumbers?.length || issueCount} issues for project ${projectNumber}...`);
const results: ProjectIssuesResponse['organization']['projectV2']['items']['nodes'] = [];
let issueStartCursor: null | string = null;
let nextPageExists = true;
let totalFetched = 0;
while (nextPageExists) {
const startCursor: null | string = issueStartCursor ? `"${issueStartCursor}"` : null; // null is needed for first page, but it cannot be a string
const query: string = `
query {
organization(login: "${owner}") {
projectV2(number: ${projectNumber}) {
items(first: ${MAX_BATCH_SIZE}, after: ${startCursor}, orderBy: { field: POSITION, direction: DESC }) {
pageInfo {
endCursor
hasNextPage
}
nodes {
__typename
id
fieldValues(first: ${issueFieldCount}) {
nodes {
__typename
... on ProjectV2ItemFieldSingleSelectValue {
__typename
name
optionId
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
fullDatabaseId
content {
__typename
... on Issue {
id
number
title
resourcePath
url
repository {
name
owner {
id
}
}
labels(first: ${labelsCount}) {
nodes {
name
}
}
}
}
}
}
}
}
}`;
const responseItems = (await octokit.graphql<ProjectIssuesResponse>(query)).organization.projectV2.items;
const responseIssues = responseItems.nodes.filter((i) => i.content?.__typename === 'Issue');
totalFetched += responseIssues.length;
responseIssues.forEach((issue) => {
if (findIssueNumbers.length) {
if (findIssueNumbersSet.has(issue.content.number)) {
results.push(issue);
findIssueNumbersSet.delete(issue.content.number);
}
} else {
results.push(issue);
}
});
if (findIssueNumbers.length && findIssueNumbersSet.size === 0) {
console.log(`Found all requested ${findIssueNumbers.length} issues`);
break;
} else if (results.length >= issueCount) {
console.log(`Fetched all requested ${issueCount} issues`);
break;
} else if (responseItems.pageInfo?.hasNextPage) {
console.log(`Fetched ${totalFetched} of ${issueCount} issues, fetching more...`);
nextPageExists = true;
if (nextPageExists) {
issueStartCursor = responseItems.pageInfo?.endCursor || null;
}
} else {
console.log('No more issues to fetch');
nextPageExists = false;
}
}
return results;
};
export const gqlGetFieldOptions = (
octokit: Octokit,
{ projectNumber, owner }: { projectNumber: number; owner: string },
limitOptions?: {
fieldCount?: number;
},
) => {
const { fieldCount = 20 } = limitOptions || {};
return octokit.graphql<FieldOptions>(`query {
organization(login: "${owner}") {
projectV2(number: ${projectNumber}) {
fields(first: ${fieldCount}) {
nodes {
__typename
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}`);
};
export const gqlUpdateFieldValue = async (
octokit: Octokit,
{ projectId, fieldId, itemId, optionId, fieldName }: UpdateFieldParams,
) => {
const mutation = `mutation{
updateProjectV2ItemFieldValue(input: {itemId: "${itemId}", fieldId: "${fieldId}", projectId: "${projectId}", value: { singleSelectOptionId: "${optionId}" }}) {
clientMutationId
projectV2Item {
id
fieldValueByName(name: "${fieldName}") {
... on ProjectV2ItemFieldSingleSelectValue {
name
optionId
}
}
}
}
}`;
return octokit.graphql<FieldUpdateResult>(mutation);
};