Skip to content
Closed
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
118 changes: 98 additions & 20 deletions actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

const { loadAgentOutput } = require("./load_agent_output.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { loadTemporaryIdMap, resolveIssueNumber } = require("./temporary_id.cjs");
const { loadTemporaryIdMap, resolveIssueNumber, generateTemporaryId, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs");

/**
* Log detailed GraphQL error information
Expand Down Expand Up @@ -611,27 +611,104 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".');
}

const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : "";
if (!draftTitle) {
throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.');
}
// Get or generate temporary ID for this draft issue
const temporaryId = output.temporary_id ?? generateTemporaryId();
const normalizedTempId = normalizeTemporaryId(temporaryId);

const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined;
const result = await github.graphql(
`mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem {
id
}
// Check if we're referencing an existing draft by temporary ID only
const existingMapping = temporaryIdMap.get(normalizedTempId);
const referencingById = existingMapping && existingMapping.draftItemId && !output.draft_title;

let itemId;

if (referencingById) {
// Referencing existing draft issue by temporary ID (no title needed)
itemId = existingMapping.draftItemId;
core.info(`✓ Draft issue found via temporary ID ${temporaryId}`);
} else {
// Creating or finding draft issue (title required)
const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : "";
if (!draftTitle) {
throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required unless referencing an existing draft by temporary_id.');
}

const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined;
// Check if a draft issue with this title already exists on the board
const existingDraftItem = await (async function (projectId, title) {
let hasNextPage = true;
let endCursor = null;

while (hasNextPage) {
const result = await github.graphql(
`query($projectId: ID!, $after: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $after) {
nodes {
id
content {
... on Issue {
id
}
... on PullRequest {
id
}
... on DraftIssue {
id
title
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}`,
{ projectId, after: endCursor }
);

const found = result.node.items.nodes.find(item => item.content && item.content.title === title);
if (found) return found;

hasNextPage = result.node.items.pageInfo.hasNextPage;
endCursor = result.node.items.pageInfo.endCursor;
}
}`,
{ projectId, title: draftTitle, body: draftBody }
);
const itemId = result.addProjectV2DraftIssue.projectItem.id;
return null;
})(projectId, draftTitle);

if (existingDraftItem) {
itemId = existingDraftItem.id;
core.info("✓ Draft issue already on board (matched by title)");

// Store the mapping for future references
temporaryIdMap.set(normalizedTempId, { draftItemId: itemId });
core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemId}`);
} else {
// Create new draft issue
const result = await github.graphql(
`mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem {
id
}
}
}`,
{ projectId, title: draftTitle, body: draftBody }
);
itemId = result.addProjectV2DraftIssue.projectItem.id;

// Store the mapping for future references
temporaryIdMap.set(normalizedTempId, { draftItemId: itemId });
core.info(`Created draft issue and stored temporary ID mapping: ${temporaryId} -> ${itemId}`);
}
}

const fieldsToUpdate = output.fields ? { ...output.fields } : {};
if (Object.keys(fieldsToUpdate).length > 0) {
Expand Down Expand Up @@ -761,6 +838,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
}

core.setOutput("item-id", itemId);
core.setOutput("temporary-id", temporaryId);
return;
}
let contentNumber = null;
Expand Down
94 changes: 92 additions & 2 deletions actions/setup/js/update_project.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,24 @@ const existingItemResponse = (contentId, itemId = "existing-item") => ({
},
});

const existingDraftItemResponse = (title, itemId = "existing-draft-item") => ({
node: {
items: {
nodes: [{ id: itemId, content: { id: "draft-content-id", title } }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});

const emptyDraftItemsResponse = () => ({
node: {
items: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});

const fieldsResponse = nodes => ({ node: { fields: { nodes } } });

const updateFieldValueResponse = () => ({
Expand Down Expand Up @@ -427,7 +445,7 @@ describe("updateProject", () => {
draft_body: "Draft body",
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), addDraftIssueResponse("draft-item-1")]);
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-item-1")]);

await updateProject(output);

Expand All @@ -450,6 +468,77 @@ describe("updateProject", () => {
await expect(updateProject(output)).rejects.toThrow(/draft_title/);
});

it("skips adding a draft issue that already exists on the board", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "draft_issue",
draft_title: "Draft title",
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-draft"), existingDraftItemResponse("Draft title", "existing-draft-item")]);

await updateProject(output);

expect(mockCore.info).toHaveBeenCalledWith("✓ Draft issue already on board (matched by title)");
expect(getOutput("item-id")).toBe("existing-draft-item");
// Should not call addProjectV2DraftIssue mutation
expect(mockGithub.graphql.mock.calls.some(([query]) => query.includes("addProjectV2DraftIssue"))).toBe(false);
});

it("creates draft issue with temporary_id and allows reference by ID", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";

// Create a shared temporary ID map to simulate persistence
const temporaryIdMap = new Map();

// First call: create with temporary_id
const createOutput = {
type: "update_project",
project: projectUrl,
content_type: "draft_issue",
draft_title: "Task 1",
temporary_id: "aw_abc123def456",
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-temp-id"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-temp-1")]);

// Get the handler factory and call it with the message
const handler = await updateProjectHandlerFactory({});
await handler(createOutput, temporaryIdMap);

expect(mockCore.info).toHaveBeenCalledWith("Created draft issue and stored temporary ID mapping: aw_abc123def456 -> draft-temp-1");
expect(getOutput("item-id")).toBe("draft-temp-1");

// Verify the temporary ID was stored
expect(temporaryIdMap.has("aw_abc123def456")).toBe(true);
expect(temporaryIdMap.get("aw_abc123def456")).toEqual({ draftItemId: "draft-temp-1" });

// Second call: reference by temporary_id only (no title needed)
const updateOutput = {
type: "update_project",
project: projectUrl,
content_type: "draft_issue",
temporary_id: "aw_abc123def456",
fields: { Status: "Done" },
};

queueResponses([
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-temp-id"),
fieldsResponse([{ id: "field-status", name: "Status", dataType: "SINGLE_SELECT", options: [{ id: "opt-done", name: "Done" }] }]),
updateFieldValueResponse(),
]);

await handler(updateOutput, temporaryIdMap);

// Check that the message was logged (may be among other info messages)
const infoCalls = mockCore.info.mock.calls.map(call => call[0]);
expect(infoCalls).toContain("✓ Draft issue found via temporary ID aw_abc123def456");
});

it("skips adding an issue that already exists on the board", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = { type: "update_project", project: projectUrl, content_type: "issue", content_number: 99 };
Expand Down Expand Up @@ -583,6 +672,7 @@ describe("updateProject", () => {
repoResponse(),
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project-draft-fields"),
emptyDraftItemsResponse(),
addDraftIssueResponse("draft-item-fields"),
fieldsResponse([{ id: "field-status", name: "Status" }]),
updateFieldValueResponse(),
Expand Down Expand Up @@ -1263,7 +1353,7 @@ describe("updateProject", () => {
draft_body: "This is a test",
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(messageProjectUrl, 60, "project-message"), addDraftIssueResponse("draft-item-message")]);
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(messageProjectUrl, 60, "project-message"), emptyDraftItemsResponse(), addDraftIssueResponse("draft-item-message")]);

const result = await messageHandler(messageWithProject, new Map());

Expand Down