Skip to content

Commit

Permalink
feat: GitHubProjectError, GitHubProjectUnknownFieldError, `GitHub…
Browse files Browse the repository at this point in the history
…ProjectUnknownFieldOptionError`, `GitHubProjectUpdateReadOnlyFieldError` (#132)

BREAKING CHANGE: When a field could not be found, the message on the
thrown error looked like this

> [github-project] "NOPE" could not be matched with any of the existing
field names: "Title", "Assignees", "Status", "Labels", "Linked pull
requests", "Reviewers", "Repository", "Milestone", "Text", "Number",
"Date", "Single select", "Iteration". If the field should be considered
optional, then set it to "nope: { name: "NOPE", optional: true}

it is now

> Project field cannot be found

The original message can still be retrieved using
`error.toHumanMessage()`

BREAKING CHANGE: When a user value cannot be matched with a field
option, the message on the thrown error looked like this

> [github-project] "unknown" is an invalid option for "Single select"

it is now

> Project field option cannot be found

The original message can still be retrieved using
`error.toHumanMessage()`

BREAKING CHANGE: if a user tried to update a read-only field, the
message on the thrown error looked like this

> [github-project] Cannot update read-only fields: "Assignees"
(.assignees)

it is now

> Project read-only field cannot be updated

---------

Co-authored-by: Tom Elliott <13594679+tmelliottjr@users.noreply.github.com>
  • Loading branch information
gr2m and tmelliottjr committed Sep 21, 2023
1 parent fe3a502 commit 9437ed7
Show file tree
Hide file tree
Showing 12 changed files with 831 additions and 59 deletions.
484 changes: 484 additions & 0 deletions README.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions api/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @ts-check

export class GitHubProjectError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
this.details = {};
}
/* c8 ignore start */
toHumanMessage() {
return this.message;
}
/* c8 ignore stop */
}

export class GitHubProjectUnknownFieldError extends GitHubProjectError {
constructor(details) {
super("Project field cannot be found");
this.details = details;
}

toHumanMessage() {
const projectFieldNames = this.details.projectFieldNames
.map((node) => `"${node.name}"`)
.join(", ");
return `"${this.details.userFieldName}" could not be matched with any of the existing field names: ${projectFieldNames}. If the field should be considered optional, then set it to "${this.details.userInternalFieldName}: { name: "${this.details.userFieldName}", optional: true}`;
}
}

export class GitHubProjectUnknownFieldOptionError extends GitHubProjectError {
constructor(details) {
super("Project field option cannot be found");
this.details = details;
}

toHumanMessage() {
const existingOptionsString = this.details.field.options
.map((option) => `"${option.name}"`)
.join(", ");

return `"${this.details.userValue}" is an invalid option for "${this.details.field.name}".\n\nKnown options are:\n${existingOptionsString}`;
}
}

export class GitHubProjectUpdateReadOnlyFieldError extends GitHubProjectError {
constructor(details) {
super("Project read-only field cannot be updated");
this.details = details;
}

toHumanMessage() {
return `Cannot update read-only fields: ${this.details.fields
.map(({ userValue, userName }) => `"${userValue}" (.${userName})`)
.join(", ")}`;
}
}
58 changes: 32 additions & 26 deletions api/lib/get-fields-update-query-and-fields.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// @ts-check

import {
GitHubProjectUnknownFieldOptionError,
GitHubProjectUpdateReadOnlyFieldError,
} from "../../index.js";
import { queryItemFieldNodes } from "./queries.js";

/**
Expand Down Expand Up @@ -45,26 +49,29 @@ export function getFieldsUpdateQueryAndFields(state, fields) {
.map((key) => [key, fields[key] === "" ? null : fields[key]])
);

const readOnlyFields = Object.keys(existingFields)
.map((key) => [key, state.fields[key].userName])
.filter(([key]) => {
const field = state.fields[key];
const readOnlyFields = Object.entries(existingFields)
.map(([id, userValue]) => ({
id,
// @ts-expect-error - assume state.fields[id] is not OptionalNonExistingField
name: String(state.fields[id].name),
userName: state.fields[id].userName,
userValue,
}))
.filter(({ id, name }) => {
const field = state.fields[id];
return READ_ONLY_FIELDS.some((readOnlyField) => {
return state.matchFieldName(
readOnlyField.toLowerCase(),

// @ts-expect-error - TODO: unclear why `field` is typed as potential "string" here
field.name.toLowerCase().trim()
name.toLowerCase().trim()
);
});
});

if (readOnlyFields.length > 0) {
throw new Error(
`[github-project] Cannot update read-only fields: ${readOnlyFields
.map(([key, value]) => `"${value}" (.${key})`)
.join(", ")}`
);
throw new GitHubProjectUpdateReadOnlyFieldError({
fields: readOnlyFields,
});
}

/** @type {Record<string, {query: string, key: string, value: string|undefined}>[]} */
Expand Down Expand Up @@ -103,9 +110,9 @@ export function getFieldsUpdateQueryAndFields(state, fields) {

const query = `
${alias}: updateProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: "${fieldId}", ${toItemFieldValueInput(
field,
valueOrOption
)}}) {
field,
valueOrOption
)}}) {
${queryNodes}
}
`;
Expand Down Expand Up @@ -186,20 +193,19 @@ function findFieldOptionIdAndValue(state, field, value) {
) || [];

if (!optionId) {
const knownOptions = Object.keys(field.optionsByValue);
const existingOptionsString = knownOptions
.map((value) => `- ${value}`)
.join("\n");
const options = Object.entries(field.optionsByValue).map(([name, id]) => {
return { name, id };
});

throw Object.assign(
new Error(
`[github-project] "${value}" is an invalid option for "${field.name}".\n\nKnown options are:\n${existingOptionsString}`
),
{
code: "E_GITHUB_PROJECT_UNKNOWN_FIELD_OPTION",
knownOptions,
userOption: value,
}
new GitHubProjectUnknownFieldOptionError({
field: {
id: field.id,
name: field.name,
options,
},
userValue: value,
})
);
}

Expand Down
16 changes: 9 additions & 7 deletions api/lib/project-fields-nodes-to-fields-map.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @ts-check

import { GitHubProjectUnknownFieldError } from "../../index.js";

/**
* Takes `project.fields` and the list of project item fieldValues nodes
* from the GraphQL query result:
Expand Down Expand Up @@ -81,16 +83,16 @@ export function projectFieldsNodesToFieldsMap(state, project, nodes) {
);

if (!node) {
const projectFieldNames = nodes
.map((node) => `"${node.name}"`)
.join(", ");
const projectFieldNames = nodes.map((node) => node.name);
if (!fieldOptional) {
throw new Error(
`[github-project] "${userFieldName}" could not be matched with any of the existing field names: ${projectFieldNames}. If the field should be considered optional, then set it to "${userInternalFieldName}: { name: "${userFieldName}", optional: true}`
);
throw new GitHubProjectUnknownFieldError({
userFieldName,
userInternalFieldName,
projectFieldNames,
});
}
project.octokit.log.info(
`[github-project] optional field "${userFieldName}" was not matched with any existing field names: ${projectFieldNames}`
`optional field "${userFieldName}" was not matched with any existing field names: ${projectFieldNames}`
);
return acc;
}
Expand Down
59 changes: 59 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,62 @@ export type GitHubProjectStateWithFields = GitHubProjectStateCommon & {
fields: ProjectFieldMap;
databaseId: string;
};

export declare class GitHubProjectError extends Error {
// This causes an odd error that I don't know how to workaround
// > Property name in type ... is not assignable to the same property in base type GitHubProjectError.
// name: "GitHubProjectError";
details: {};
toHumanMessage(): string;
}

type GitHubProjectUnknownFieldErrorDetails = {
projectFieldNames: string[];
userFieldName: string;
userInternalFieldName: string;
};

export declare class GitHubProjectUnknownFieldError<
TDetails extends GitHubProjectUnknownFieldErrorDetails,
> extends GitHubProjectError {
name: "GitHubProjectUnknownFieldError";
details: TDetails;
constructor(details: TDetails);
}

type GitHubProjectUnknownFieldOptionErrorDetails = {
userValue: string;
field: {
id: string;
name: string;
options: {
id: string;
name: string;
}[];
};
};

export declare class GitHubProjectUnknownFieldOptionError<
TDetails extends GitHubProjectUnknownFieldOptionErrorDetails,
> extends GitHubProjectError {
name: "GitHubProjectUnknownFieldOptionError";
details: TDetails;
constructor(details: TDetails);
}

type GitHubProjectUpdateReadOnlyFieldErrorDetails = {
fields: {
id: string;
name: string;
userName: string;
userValue: string | null;
}[];
};

export declare class GitHubProjectUpdateReadOnlyFieldError<
TDetails extends GitHubProjectUpdateReadOnlyFieldErrorDetails,
> extends GitHubProjectError {
name: "GitHubProjectUpdateReadOnlyFieldError";
details: TDetails;
constructor(details: TDetails);
}
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const BUILT_IN_FIELDS = {
status: "Status",
};

export * from "./api/errors.js";

export default class GitHubProject {
/**
* @param {import(".").GitHubProjectOptions} options
Expand Down
49 changes: 48 additions & 1 deletion index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expectType, expectNotType } from "tsd";
import { Octokit } from "@octokit/core";
import GitHubProject from "./index";
import GitHubProject, {
GitHubProjectError,
GitHubProjectUnknownFieldError,
GitHubProjectUnknownFieldOptionError,
} from "./index";

export function smokeTest() {
expectType<typeof GitHubProject>(GitHubProject);
Expand Down Expand Up @@ -641,3 +645,46 @@ export async function testGetProperties() {
url: string;
}>(properties);
}

export function testGitHubProjectError() {
const error = new GitHubProjectError();

// setting type for GitHubProjectError.name is causing a type error, see comment in index.d.ts
// expectType<"GitHubProjectError">(error.name);
expectType<{}>(error.details);
expectType<string>(error.toHumanMessage());
}

export function testGitHubProjectUnknownFieldError() {
const details = {
projectFieldNames: ["one", "two"],
userFieldName: "Three",
userInternalFieldName: "three",
};
const error = new GitHubProjectUnknownFieldError(details);

expectType<"GitHubProjectUnknownFieldError">(error.name);
expectType<typeof details>(error.details);
expectType<string>(error.toHumanMessage());
}

export function testGitHubProjectUnknownFieldOptionError() {
const details = {
field: {
id: "field id",
name: "field name",
options: [
{
id: "option id",
name: "option name",
},
],
},
userValue: "user value",
};
const error = new GitHubProjectUnknownFieldOptionError(details);

expectType<"GitHubProjectUnknownFieldOptionError">(error.name);
expectType<typeof details>(error.details);
expectType<string>(error.toHumanMessage());
}
5 changes: 4 additions & 1 deletion test/recorded/api.getProperties-field-not-found/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export function test(defaultTestProject) {
() => {
throw new Error("Should not resolve");
},
(error) => error
(error) => ({
error,
humanMessage: error.toHumanMessage(),
})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function test(defaultTestProject, itemId = "PVTI_1") {
() => {
throw new Error("Should not resolve");
},
(error) => error
(error) => ({
error,
humanMessage: error.toHumanMessage(),
})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export function test(project, itemId = "PVTI_1") {
() => {
throw new Error("Expected error");
},
(error) => error
(error) => ({
error,
humanMessage: error.toHumanMessage(),
})
);
}

0 comments on commit 9437ed7

Please sign in to comment.