import { BaseGitHubRepository } from "./BaseRepository";
import { Project, CreateProject, ProjectRepository, ProjectId, ProjectView, CustomField, ViewLayout } from "../../../domain/types";
import { ResourceType, ResourceStatus } from "../../../domain/resource-types";
import { GitHubTypeConverter } from "../util/conversion";
import {
mapToGraphQLFieldType,
mapFromGraphQLFieldType,
CreateProjectV2FieldResponse,
UpdateProjectV2FieldResponse
} from "../util/graphql-helpers";
import {
GraphQLFieldType,
CreateProjectV2ViewResponse,
UpdateProjectV2ViewResponse,
mapToGraphQLViewLayout
} from "../graphql-types";
interface GitHubProject {
id: string;
title: string;
shortDescription: string | null;
closed: boolean;
createdAt: string;
updatedAt: string;
version?: number;
}
interface CreateProjectResponse {
createProjectV2: {
projectV2: GitHubProject;
};
}
interface UpdateProjectResponse {
updateProjectV2: {
projectV2: GitHubProject;
};
}
interface GetProjectResponse {
node: GitHubProject | null;
}
interface ListProjectsResponse {
repository: {
projectsV2: {
nodes: GitHubProject[];
};
};
}
export class GitHubProjectRepository extends BaseGitHubRepository implements ProjectRepository {
async create(data: CreateProject): Promise<Project> {
// Step 1: Create project with valid CreateProjectV2Input schema
const createMutation = `
mutation($input: CreateProjectV2Input!) {
createProjectV2(input: $input) {
projectV2 {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
`;
// Build input according to official GitHub schema
const createInput: any = {
ownerId: this.owner,
title: data.title,
};
// Add optional repositoryId if available
if (this.repo) {
createInput.repositoryId = this.repo;
}
const createResponse = await this.graphql<CreateProjectResponse>(createMutation, {
input: createInput,
});
let project = createResponse.createProjectV2.projectV2;
// Step 2: Update project with description if provided (shortDescription is not part of CreateProjectV2Input)
if (data.shortDescription) {
const updateMutation = `
mutation($input: UpdateProjectV2Input!) {
updateProjectV2(input: $input) {
projectV2 {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
`;
const updateResponse = await this.graphql<UpdateProjectResponse>(updateMutation, {
input: {
projectId: project.id,
shortDescription: data.shortDescription,
},
});
project = updateResponse.updateProjectV2.projectV2;
}
return {
id: project.id,
type: ResourceType.PROJECT,
title: project.title,
description: project.shortDescription || "",
owner: this.owner,
number: parseInt(project.id.split('_').pop() || '0'),
url: `https://github.com/orgs/${this.owner}/projects/${parseInt(project.id.split('_').pop() || '0')}`,
status: project.closed ? ResourceStatus.CLOSED : ResourceStatus.ACTIVE,
visibility: data.visibility || "private",
views: data.views || [],
fields: data.fields || [],
createdAt: project.createdAt,
updatedAt: project.updatedAt,
closed: project.closed
};
}
async update(id: ProjectId, data: Partial<Project>): Promise<Project> {
const mutation = `
mutation($input: UpdateProjectV2Input!) {
updateProjectV2(input: $input) {
projectV2 {
id
title
shortDescription
closed
updatedAt
}
}
}
`;
const response = await this.graphql<UpdateProjectResponse>(mutation, {
input: {
projectId: id,
title: data.title,
shortDescription: data.description,
closed: data.status === ResourceStatus.CLOSED,
},
});
const project = response.updateProjectV2.projectV2;
return {
id: project.id,
type: ResourceType.PROJECT,
title: project.title,
description: project.shortDescription || "",
owner: this.owner,
number: parseInt(project.id.split('_').pop() || '0'),
url: `https://github.com/orgs/${this.owner}/projects/${parseInt(project.id.split('_').pop() || '0')}`,
status: project.closed ? ResourceStatus.CLOSED : ResourceStatus.ACTIVE,
visibility: "private",
views: [],
fields: [],
createdAt: new Date().toISOString(),
updatedAt: project.updatedAt,
closed: project.closed
};
}
async delete(id: ProjectId): Promise<void> {
const mutation = `
mutation($input: DeleteProjectV2Input!) {
deleteProjectV2(input: $input) {
projectV2 {
id
}
}
}
`;
await this.graphql(mutation, {
input: {
projectId: id,
},
});
}
async findById(id: ProjectId): Promise<Project | null> {
const query = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
`;
const response = await this.graphql<GetProjectResponse>(query, { id });
if (!response.node) return null;
const project = response.node;
return {
id: project.id,
type: ResourceType.PROJECT,
title: project.title,
description: project.shortDescription || "",
owner: this.owner,
number: parseInt(project.id.split('_').pop() || '0'),
url: `https://github.com/orgs/${this.owner}/projects/${parseInt(project.id.split('_').pop() || '0')}`,
status: project.closed ? ResourceStatus.CLOSED : ResourceStatus.ACTIVE,
visibility: "private",
views: [],
fields: [],
createdAt: project.createdAt,
updatedAt: project.updatedAt,
closed: project.closed
};
}
async findAll(): Promise<Project[]> {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
projectsV2(first: 100) {
nodes {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
}
`;
const response = await this.graphql<ListProjectsResponse>(query, {
owner: this.owner,
repo: this.repo,
});
return response.repository.projectsV2.nodes.map((project: GitHubProject) => ({
id: project.id,
type: ResourceType.PROJECT,
title: project.title,
description: project.shortDescription || "",
owner: this.owner,
number: parseInt(project.id.split('_').pop() || '0'),
url: `https://github.com/orgs/${this.owner}/projects/${parseInt(project.id.split('_').pop() || '0')}`,
status: project.closed ? ResourceStatus.CLOSED : ResourceStatus.ACTIVE,
visibility: "private",
views: [],
fields: [],
createdAt: project.createdAt,
updatedAt: project.updatedAt,
closed: project.closed
}));
}
async findByOwner(owner: string): Promise<Project[]> {
const query = `
query($owner: String!) {
user(login: $owner) {
projectsV2(first: 100) {
nodes {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
organization(login: $owner) {
projectsV2(first: 100) {
nodes {
id
title
shortDescription
closed
createdAt
updatedAt
}
}
}
}
`;
try {
const response = await this.graphql<any>(query, { owner });
// Combine projects from both user and organization contexts
const userProjects = response.user?.projectsV2?.nodes || [];
const orgProjects = response.organization?.projectsV2?.nodes || [];
const projects = [...userProjects, ...orgProjects].map((project: GitHubProject) => ({
id: project.id,
type: ResourceType.PROJECT,
title: project.title,
description: project.shortDescription || "",
owner: owner,
number: parseInt(project.id.split('_').pop() || '0'),
url: `https://github.com/${owner}/projects/${parseInt(project.id.split('_').pop() || '0')}`,
status: project.closed ? ResourceStatus.CLOSED : ResourceStatus.ACTIVE,
visibility: "private",
views: [],
fields: [],
createdAt: project.createdAt,
updatedAt: project.updatedAt,
closed: project.closed
}));
return projects;
} catch (error) {
this.logger.error(`Failed to fetch projects for owner ${owner}`, error);
throw this.handleGraphQLError(error);
}
}
async createView(projectId: ProjectId, name: string, layout: ProjectView["layout"]): Promise<ProjectView> {
const mutation = `
mutation($input: CreateProjectV2ViewInput!) {
createProjectV2View(input: $input) {
projectV2View {
id
name
layout
}
}
}
`;
try {
const graphqlLayout = mapToGraphQLViewLayout(layout);
const variables = {
input: {
projectId,
name,
layout: graphqlLayout
}
};
const response = await this.graphql<CreateProjectV2ViewResponse>(mutation, variables);
if (!response.createProjectV2View?.projectV2View) {
throw new Error('Failed to create project view: Invalid response from GitHub API');
}
const view = response.createProjectV2View.projectV2View;
return {
id: view.id,
name: view.name,
layout: view.layout.toLowerCase() as ViewLayout,
fields: [],
sortBy: [],
groupBy: undefined,
filters: []
};
} catch (error) {
this.logger.error(`Failed to create project view for project ${projectId}`, error);
throw this.handleGraphQLError(error);
}
}
async updateView(projectId: ProjectId, viewId: string, data: Partial<ProjectView>): Promise<ProjectView> {
const mutation = `
mutation($input: UpdateProjectV2ViewInput!) {
updateProjectV2View(input: $input) {
projectV2View {
id
name
layout
}
}
}
`;
try {
const input: Record<string, any> = {
projectId,
viewId
};
if (data.name !== undefined) {
input.name = data.name;
}
if (data.layout !== undefined) {
input.layout = mapToGraphQLViewLayout(data.layout);
}
const response = await this.graphql<UpdateProjectV2ViewResponse>(mutation, {
input
});
if (!response.updateProjectV2View?.projectV2View) {
throw new Error('Failed to update project view: Invalid response from GitHub API');
}
const view = response.updateProjectV2View.projectV2View;
return {
id: view.id,
name: view.name,
layout: view.layout.toLowerCase() as ViewLayout,
fields: [],
sortBy: [],
groupBy: undefined,
filters: []
};
} catch (error) {
this.logger.error(`Failed to update project view ${viewId}`, error);
throw this.handleGraphQLError(error);
}
}
async deleteView(projectId: ProjectId, viewId: string): Promise<void> {
const mutation = `
mutation($input: DeleteProjectV2ViewInput!) {
deleteProjectV2View(input: $input) {
projectV2View {
id
}
}
}
`;
try {
await this.graphql(mutation, {
input: {
projectId,
viewId
}
});
this.logger.info(`Deleted project view ${viewId} from project ${projectId}`);
} catch (error) {
this.logger.error(`Failed to delete project view ${viewId}`, error);
throw this.handleGraphQLError(error);
}
}
async createField(projectId: ProjectId, field: Omit<CustomField, "id">): Promise<CustomField> {
const mutation = `
mutation($input: CreateProjectV2FieldInput!) {
createProjectV2Field(input: $input) {
projectV2Field {
id
name
dataType
}
}
}
`;
try {
const githubFieldType = mapToGraphQLFieldType(field.type);
const variables: any = {
input: {
projectId,
dataType: githubFieldType,
name: field.name,
}
};
if (field.type === 'single_select' && field.options && field.options.length > 0) {
variables.input.singleSelectOptions = field.options.map(option => ({
name: option.name,
description: option.description || null,
color: option.color || null
}));
}
if (field.type === 'iteration' && field.config) {
if (field.config.iterationDuration) {
variables.input.iterationDuration = field.config.iterationDuration;
}
if (field.config.iterationStart) {
variables.input.iterationStartDate = field.config.iterationStart;
}
}
const response = await this.graphql<CreateProjectV2FieldResponse>(mutation, variables);
const createdField = response.createProjectV2Field.projectV2Field;
// Since the createdField object doesn't have a dataType property, we need to fetch it
const fieldDetails = await this.getField(projectId, createdField.id);
return {
id: createdField.id,
name: createdField.name,
type: fieldDetails?.type || field.type, // Use fetched type or fallback to original
options: field.options || [],
description: field.description,
required: field.required || false,
defaultValue: field.defaultValue,
validation: field.validation,
config: field.config
};
} catch (error) {
this.logger.error(`Failed to create field ${field.name} for project ${projectId}`, error);
throw this.handleGraphQLError(error);
}
}
async updateField(projectId: ProjectId, fieldId: string, updates: Partial<CustomField>): Promise<CustomField> {
const mutation = `
mutation($input: UpdateProjectV2FieldInput!) {
updateProjectV2Field(input: $input) {
projectV2Field {
id
name
dataType
}
}
}
`;
try {
const variables: any = {
input: {
projectId,
fieldId,
name: updates.name,
}
};
// Handle single_select option updates
if (updates.type === 'single_select' && updates.options && updates.options.length > 0) {
const currentField = await this.getField(projectId, fieldId);
if (currentField && currentField.type === 'single_select') {
const currentOptions = currentField.options || [];
const newOptions = updates.options;
// Find options to add (in newOptions but not in currentOptions)
const optionsToAdd = newOptions.filter(
newOpt => !currentOptions.some(currOpt => currOpt.name === newOpt.name)
);
// Find options to delete (in currentOptions but not in newOptions)
const optionsToDelete = currentOptions.filter(
currOpt => !newOptions.some(newOpt => newOpt.name === currOpt.name)
);
// Find options to update (same name but different color)
const optionsToUpdate = newOptions.filter(newOpt => {
const currOpt = currentOptions.find(c => c.name === newOpt.name);
return currOpt && currOpt.color !== newOpt.color;
});
// Add new options
for (const option of optionsToAdd) {
const addMutation = `
mutation($input: AddProjectV2SingleSelectOptionInput!) {
addProjectV2SingleSelectOption(input: $input) {
projectV2SingleSelectOption {
id
name
}
}
}
`;
await this.graphql(addMutation, {
input: {
projectId,
fieldId,
name: option.name,
color: option.color || 'GRAY'
}
});
}
// Update existing options (color changes)
for (const option of optionsToUpdate) {
const currOpt = currentOptions.find(c => c.name === option.name);
if (currOpt && currOpt.id) {
const updateMutation = `
mutation($input: UpdateProjectV2SingleSelectOptionInput!) {
updateProjectV2SingleSelectOption(input: $input) {
projectV2SingleSelectOption {
id
name
color
}
}
}
`;
await this.graphql(updateMutation, {
input: {
projectId,
fieldId,
optionId: currOpt.id,
name: option.name,
color: option.color || 'GRAY'
}
});
}
}
// Delete removed options
for (const option of optionsToDelete) {
if (option.id) {
const deleteMutation = `
mutation($input: DeleteProjectV2SingleSelectOptionInput!) {
deleteProjectV2SingleSelectOption(input: $input) {
deletedOptionId
}
}
`;
await this.graphql(deleteMutation, {
input: {
projectId,
fieldId,
optionId: option.id
}
});
}
}
}
}
const response = await this.graphql<UpdateProjectV2FieldResponse>(mutation, variables);
const updatedField = response.updateProjectV2Field.projectV2Field;
return await this.getField(projectId, fieldId) as CustomField;
} catch (error) {
this.logger.error(`Failed to update field ${fieldId} for project ${projectId}`, error);
throw this.handleGraphQLError(error);
}
}
async deleteField(projectId: ProjectId, fieldId: string): Promise<void> {
const mutation = `
mutation($input: DeleteProjectV2FieldInput!) {
deleteProjectV2Field(input: $input) {
deletedFieldId
}
}
`;
try {
await this.graphql(mutation, {
input: {
projectId,
fieldId
}
});
} catch (error) {
this.logger.error(`Failed to delete field ${fieldId} from project ${projectId}`, error);
throw this.handleGraphQLError(error);
}
}
private async getField(projectId: ProjectId, fieldId: string): Promise<CustomField | null> {
const query = `
query($projectId: ID!, $fieldId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
field(id: $fieldId) {
... on ProjectV2Field {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
description
color
}
}
... on ProjectV2IterationField {
id
name
dataType
configuration {
duration
startDay
iterations {
id
title
startDate
endDate
}
}
}
}
}
}
}
`;
try {
const response = await this.graphql<any>(query, { projectId, fieldId });
const fieldData = response.node?.field;
if (!fieldData) return null;
const customField: CustomField = {
id: fieldData.id,
name: fieldData.name,
type: mapFromGraphQLFieldType(fieldData.dataType as GraphQLFieldType),
description: "",
};
if (fieldData.dataType === 'SINGLE_SELECT' && fieldData.options) {
customField.options = fieldData.options.map((opt: any) => ({
id: opt.id,
name: opt.name,
description: opt.description,
color: opt.color
}));
}
if (fieldData.dataType === 'ITERATION' && fieldData.configuration) {
customField.config = {
iterationDuration: fieldData.configuration.duration,
iterationStart: fieldData.configuration.iterations[0]?.startDate
};
}
return customField;
} catch (error) {
this.logger.error(`Failed to fetch field ${fieldId} for project ${projectId}`, error);
throw this.handleGraphQLError(error);
}
}
}