Summary
createIssue builds the GraphQL CreateIssueInput from human-readable names but GitHub's createIssue mutation expects GraphQL node IDs for three of those fields. As a result the create_issue tool fails for every repository, with an error that names an unrelated-looking string:
Could not resolve to a node with the global id of 'priority:medium'
The same defect exists in update().
Environment
|
|
| Version |
1.1.0 (npm latest, package.json on main) |
| Commit verified |
194a0e6 (main, 2026-07-15) — current HEAD at time of writing |
| Transport |
stdio, wrapped by a local HTTP proxy |
| Token scopes |
repo, project (list/read tools work fine against the same repos) |
Reproduction
Result:
GitHub API error while executing GraphQL query: Request failed due to following response errors:
- Could not resolve to a node with the global id of 'priority:medium'
Passing labels explicitly does not help — even a label that exists in the repository fails:
- Could not resolve to a node with the global id of 'backlog'
(backlog is a real label in the target repository; gh label list shows it.)
Note on ordering: unpatched, the mutation fails earlier — on repositoryId — because that field has the same defect. I had already patched repositoryId locally, which is why the error surfaces at labelIds. Fixing only repositoryId therefore just moves the failure one field to the right.
Root cause
Three fields in the same mutation pass names into ID parameters — src/infrastructure/github/repositories/GitHubIssueRepository.ts:100-110:
const response = await this.graphql<CreateIssueResponse>(mutation, {
input: {
repositoryId: this.repo, // repo NAME, not node ID
title: data.title,
body: data.description,
assigneeIds: data.assignees, // login names, not node IDs
labelIds: data.labels, // label names, not node IDs
milestoneId: data.milestoneId,
},
});
update() repeats it at lines 151–152 (assigneeIds, labelIds).
GitHub's CreateIssueInput requires ID! for repositoryId and [ID!] for assigneeIds / labelIds. Names are rejected with the Could not resolve to a node with the global id of … error, and GitHub reports only the first unresolvable value — which is why the message looks unrelated to what the caller passed.
Why it fails even when the caller passes nothing
src/services/IssueService.ts:81-83 appends two synthetic labels:
const labels = data.labels || [];
if (data.priority) labels.push(`priority:${data.priority}`);
if (data.type) labels.push(`type:${data.type}`);
and src/infrastructure/tools/ToolSchemas.ts:87-88 makes those unconditional:
priority: z.enum(["high", "medium", "low"]).default("medium"),
type: z.enum(["bug", "feature", "enhancement", "documentation"]).default("feature"),
So every call carries at least priority:medium and type:feature. Because z.enum rejects "", there is no argument combination that avoids this — the tool cannot be made to work from the caller side.
Two follow-on effects worth flagging separately:
- These labels rarely exist in the target repo, so even after an ID-resolution fix they would resolve to nothing.
labels is mutated in place (data.labels || [] returns the caller's array, then .push), so the caller's array grows as a side effect.
Why tests don't catch it
src/__tests__/unit/infrastructure/github/repositories/GitHubIssueRepository.test.ts mocks the client wholesale:
The mutation variables are never asserted, so any value passes. A single assertion on the input object would have caught all three fields.
Impact
create_issue and label-carrying update_issue are unusable against any repository. Read tools (list_issues, get_issue) are unaffected — they map node → name in the other direction, which is correct.
Proposed fix
1. Resolve names to node IDs in the repository layer. A single query covers labels and assignees:
// BaseRepository
private async resolveRepoNodeId(): Promise<string> {
const q = `query($owner:String!,$repo:String!){ repository(owner:$owner,name:$repo){ id } }`;
const { repository } = await this.graphql<{repository:{id:string}}>(q, {});
return repository.id;
}
private async resolveLabelIds(names?: string[]): Promise<string[]> {
if (!names?.length) return [];
const q = `query($owner:String!,$repo:String!){
repository(owner:$owner,name:$repo){ labels(first:100){ nodes { id name } } } }`;
const { repository } = await this.graphql<any>(q, {});
const byName = new Map<string,string>(repository.labels.nodes.map((n:any) => [n.name, n.id]));
const missing = names.filter(n => !byName.has(n));
if (missing.length) {
// surface, don't invent: creating labels silently would mutate the repo's taxonomy
console.error(`[create_issue] unknown labels skipped: ${missing.join(", ")}`);
}
return names.map(n => byName.get(n)).filter((id): id is string => !!id);
}
Then in create() and update():
repositoryId: await this.resolveRepoNodeId(),
assigneeIds: await this.resolveAssigneeIds(data.assignees),
labelIds: await this.resolveLabelIds(data.labels),
(Repos with more than 100 labels need pagination; first: 100 matches the existing read queries.)
Alternative: use the REST endpoint POST /repos/{owner}/{repo}/issues, which accepts label names and assignee logins directly and needs no resolution at all. That is the smaller change if GraphQL isn't required here for other reasons.
2. Stop synthesising priority: / type: labels, or make them opt-in. As written they are mandatory (schema defaults) and almost never exist as labels. Options, in order of preference:
- Drop the label injection; keep
priority/type as metadata for the project-board fields where they belong.
- Or: remove
.default(...) so the labels only appear when explicitly requested, and document that the repo must define priority:* / type:*.
Also worth fixing the in-place mutation: const labels = [...(data.labels ?? [])].
3. Assert the mutation input in tests. One assertion on the input object passed to graphql would have prevented all of this:
expect(mockClient.graphql).toHaveBeenCalledWith(
expect.any(String),
{ input: expect.objectContaining({ repositoryId: "R_kgDO…" }) }
);
I'm happy to open a PR for any of the above — say which direction you prefer (GraphQL resolution vs. REST) and whether the priority/type labels should stay.
Summary
createIssuebuilds the GraphQLCreateIssueInputfrom human-readable names but GitHub'screateIssuemutation expects GraphQL node IDs for three of those fields. As a result thecreate_issuetool fails for every repository, with an error that names an unrelated-looking string:The same defect exists in
update().Environment
1.1.0(npmlatest, package.json onmain)194a0e6(main, 2026-07-15) — currentHEADat time of writingrepo,project(list/read tools work fine against the same repos)Reproduction
Result:
Passing labels explicitly does not help — even a label that exists in the repository fails:
{ "title": "Test", "description": "Test body", "labels": ["backlog"] }(
backlogis a real label in the target repository;gh label listshows it.)Root cause
Three fields in the same mutation pass names into ID parameters —
src/infrastructure/github/repositories/GitHubIssueRepository.ts:100-110:update()repeats it at lines 151–152 (assigneeIds,labelIds).GitHub's
CreateIssueInputrequiresID!forrepositoryIdand[ID!]forassigneeIds/labelIds. Names are rejected with theCould not resolve to a node with the global id of …error, and GitHub reports only the first unresolvable value — which is why the message looks unrelated to what the caller passed.Why it fails even when the caller passes nothing
src/services/IssueService.ts:81-83appends two synthetic labels:and
src/infrastructure/tools/ToolSchemas.ts:87-88makes those unconditional:So every call carries at least
priority:mediumandtype:feature. Becausez.enumrejects"", there is no argument combination that avoids this — the tool cannot be made to work from the caller side.Two follow-on effects worth flagging separately:
labelsis mutated in place (data.labels || []returns the caller's array, then.push), so the caller's array grows as a side effect.Why tests don't catch it
src/__tests__/unit/infrastructure/github/repositories/GitHubIssueRepository.test.tsmocks the client wholesale:The mutation variables are never asserted, so any value passes. A single assertion on the
inputobject would have caught all three fields.Impact
create_issueand label-carryingupdate_issueare unusable against any repository. Read tools (list_issues,get_issue) are unaffected — they map node → name in the other direction, which is correct.Proposed fix
1. Resolve names to node IDs in the repository layer. A single query covers labels and assignees:
Then in
create()andupdate():(Repos with more than 100 labels need pagination;
first: 100matches the existing read queries.)Alternative: use the REST endpoint
POST /repos/{owner}/{repo}/issues, which accepts label names and assignee logins directly and needs no resolution at all. That is the smaller change if GraphQL isn't required here for other reasons.2. Stop synthesising
priority:/type:labels, or make them opt-in. As written they are mandatory (schema defaults) and almost never exist as labels. Options, in order of preference:priority/typeas metadata for the project-board fields where they belong..default(...)so the labels only appear when explicitly requested, and document that the repo must definepriority:*/type:*.Also worth fixing the in-place mutation:
const labels = [...(data.labels ?? [])].3. Assert the mutation input in tests. One assertion on the
inputobject passed tographqlwould have prevented all of this:I'm happy to open a PR for any of the above — say which direction you prefer (GraphQL resolution vs. REST) and whether the
priority/typelabels should stay.