Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions scripts/gh-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ describe("GitHubClient project-board membership", () => {
const ISSUE_NUMBER = 2390;
const ISSUE_NODE_ID = "I_kwDOtestnode";
const NEW_ITEM_ID = "PVTI_lADOnewitem";
// The board's single-select option ids for "Backlog" and "Done".
const BACKLOG_OPTION_ID = "230488b9";
const DONE_OPTION_ID = "58fb1205";

function makeClientWithStubbedOctokit(options: { alreadyOnBoard: boolean }) {
const graphqlCalls: Array<{ query: string; vars: Record<string, unknown> }> = [];
Expand Down Expand Up @@ -210,6 +213,67 @@ describe("GitHubClient project-board membership", () => {
}
});

// A project item with no Status sits on the board in no column, and reads
// back with a blank status that looks like the issue is not on the board.
test("creating an issue sets its board status to Backlog on the item the add returned", async () => {
const { client, graphqlCalls } = makeClientWithStubbedOctokit({ alreadyOnBoard: false });

await client.createIssue("Title", "Body");

const updates = graphqlCalls.filter((c) => c.query.includes("updateProjectV2ItemFieldValue"));
expect(updates).toHaveLength(1);
expect(updates[0].vars.itemId).toBe(NEW_ITEM_ID);
expect(updates[0].vars.value).toEqual({ singleSelectOptionId: BACKLOG_OPTION_ID });

// The item id comes from the add: the board is never listed to find it.
expect(graphqlCalls.find((c) => c.query.includes("GetProjectItems"))).toBeUndefined();
});

test("a failed status write still returns the created issue and warns", async () => {
const { client } = makeClientWithStubbedOctokit({ alreadyOnBoard: false });
(client as unknown as { octokit: { graphql: unknown } }).octokit.graphql = mock(async (query: string) => {
if (query.includes("addProjectV2ItemById")) {
return { addProjectV2ItemById: { item: { id: NEW_ITEM_ID } } };
}
throw new Error("status field unreachable");
});
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});

try {
const issue = await client.createIssue("Title", "Body");

expect(issue.number).toBe(ISSUE_NUMBER);
expect(issue.addedToProject).toBe(true);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`#${ISSUE_NUMBER}`));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("status could not be"));
// The cause is in the warning, so a stale field or option id is diagnosable.
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("status field unreachable"));
} finally {
warnSpy.mockRestore();
}
});

test("a failed board add attempts no status write", async () => {
const { client } = makeClientWithStubbedOctokit({ alreadyOnBoard: false });
const queries: string[] = [];
(client as unknown as { octokit: { graphql: unknown } }).octokit.graphql = mock(async (query: string) => {
queries.push(query);
throw new Error("board unreachable");
});
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});

try {
await client.createIssue("Title", "Body");

expect(queries).toHaveLength(1);
expect(queries[0]).toContain("addProjectV2ItemById");
expect(warnSpy).toHaveBeenCalledTimes(1);
} finally {
warnSpy.mockRestore();
}
});

test("a status update adds a missing issue to the board instead of failing", async () => {
const { client, graphqlCalls } = makeClientWithStubbedOctokit({ alreadyOnBoard: false });

Expand All @@ -227,6 +291,8 @@ describe("GitHubClient project-board membership", () => {
// The status write must target the item the add returned.
const update = graphqlCalls.find((c) => c.query.includes("updateProjectV2ItemFieldValue"));
expect(update!.vars.itemId).toBe(NEW_ITEM_ID);
// And carry the option the caller asked for, not the one a new issue gets.
expect(update!.vars.value).toEqual({ singleSelectOptionId: DONE_OPTION_ID });
});

test("an issue already on the board is not re-added", async () => {
Expand Down
7 changes: 4 additions & 3 deletions scripts/gh-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ class NodeSpaceGitHubManager {
console.log(`#${issue.number}: ${title}`);
console.log(`URL: ${issue.url}`);
// GitHubClient.createIssue itself warns (console.warn) when the
// board add fails -- no need to re-derive that from
// `issue.addedToProject` here too.
// board add or the status write fails -- no need to re-derive that
// from `issue.addedToProject` here too.

return issue;
} catch (error: unknown) {
Expand Down Expand Up @@ -357,7 +357,8 @@ class NodeSpaceGitHubManager {
console.log(`No existing open issue found -- created #${issue.number}`);
console.log(`URL: ${issue.url}`);
// GitHubClient.createIssue itself warns (console.warn) when the
// issue could not be added to the project board -- e.g. every
// issue could not be added to the project board, or its status
// could not be set -- e.g. every
// invocation of this helper from a scheduled CI workflow, whose
// token can never carry Projects v2 write scope. No need to
// re-derive that from `issue.addedToProject` here too.
Expand Down
79 changes: 50 additions & 29 deletions scripts/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ interface GitHubIssue {
body: string;
}

// The board status `createIssue` gives a new issue: filed, not yet queued.
const NEW_ISSUE_STATUS = "Backlog";

export class GitHubClient {
private octokit: Octokit;

Expand Down Expand Up @@ -240,9 +243,7 @@ export class GitHubClient {
issueNumbers: number[],
status: keyof typeof this.statusOptions
): Promise<Array<{ issueNumber: number; success: boolean; error?: string }>> {
const statusOptionId = this.statusOptions[status];

if (!statusOptionId) {
if (!(status in this.statusOptions)) {
throw new Error(`Invalid status: ${status}. Valid options: ${Object.keys(this.statusOptions).join(", ")}`);
}

Expand All @@ -262,29 +263,7 @@ export class GitHubClient {
itemId = await this.addIssueToProject(nodeId);
}

const mutation = `
mutation UpdateProjectItemField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: $value
}) {
projectV2Item {
id
}
}
}
`;

await this.octokit.graphql(mutation, {
projectId: this.projectId,
itemId,
fieldId: this.statusFieldId,
value: {
singleSelectOptionId: statusOptionId
}
});
await this.setItemStatus(itemId, status);

results.push({ issueNumber, success: true });

Expand Down Expand Up @@ -491,9 +470,8 @@ export class GitHubClient {
// Board placement is a convenience, not the point of creating the issue,
// so a failure here is reported rather than thrown: the issue itself
// exists and its number is what the caller actually needs.
const addedToProject = await this.addIssueToProject(response.data.node_id)
.then(() => true)
.catch(() => false);
const itemId = await this.addIssueToProject(response.data.node_id).catch(() => null);
const addedToProject = itemId !== null;

// Warn here rather than leaving it to each caller to re-derive from
// `addedToProject` -- this is the one place that actually knows the add
Expand All @@ -511,6 +489,20 @@ export class GitHubClient {
"GITHUB_TOKEN). It will be added automatically the next time `gh:status` is run " +
"on it with a token that has project write access.",
);
} else {
// A new issue is filed, not queued: put it in Backlog. Without a status
// the item sits on the board in no column, and reads back with a blank
// status that is easy to mistake for the issue not being on the board.
// The cause is printed: with the add having just succeeded, a failure
// here is most likely a stale status field or option id, and the
// message is what tells that apart from an outage.
await this.setItemStatus(itemId, NEW_ISSUE_STATUS).catch((error: unknown) => {
console.warn(
`⚠️ Issue #${response.data.number} was added to the project board but its status could not be ` +
`set to '${NEW_ISSUE_STATUS}' (${error instanceof Error ? error.message : String(error)}). ` +
"Run `gh:status` on it to set one.",
);
});
}

return {
Expand All @@ -520,6 +512,35 @@ export class GitHubClient {
};
}

/**
* Write the Status field of one project item.
*/
private async setItemStatus(itemId: string, status: keyof typeof this.statusOptions): Promise<void> {
const mutation = `
mutation UpdateProjectItemField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: $value
}) {
projectV2Item {
id
}
}
}
`;

await this.octokit.graphql(mutation, {
projectId: this.projectId,
itemId,
fieldId: this.statusFieldId,
value: {
singleSelectOptionId: this.statusOptions[status]
}
});
}

/**
* Add an existing issue to the ProjectV2 board by its GraphQL node ID.
*
Expand Down