-
Notifications
You must be signed in to change notification settings - Fork 25
create_issue always fails: names passed where GraphQL expects node IDs (repositoryId, assigneeIds, labelIds) #26
Description
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
// tools/call { "name": "create_issue", "arguments": { "title": "Test", "description": "Test body" // no labels, no priority, no type } }
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:
{ "title": "Test", "description": "Test body", "labels": ["backlog"] } - 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 patchedrepositoryIdlocally, which is why the error surfaces atlabelIds. Fixing onlyrepositoryIdtherefore 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.
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.ts mocks the client wholesale:
graphql: jest.fn(),
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/typeas 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 definepriority:*/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.