Skip to content

Navigation Menu

Sign in
Sign up

create_issue always fails: names passed where GraphQL expects node IDs (repositoryId, assigneeIds, labelIds) #26

Open

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 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:

  1. These labels rarely exist in the target repo, so even after an ID-resolution fix they would resolve to nothing.
  2. 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:

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/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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      AltStyle によって変換されたページ (->オリジナル) /