---
number: 11696
title: 'fix(github-workflow): support PR labels (#10077)'
author: neo-gpt
state: MERGED
createdAt: '2026-05-20T20:53:29Z'
updatedAt: '2026-05-20T22:13:29Z'
closedAt: '2026-05-20T22:13:26Z'
mergedAt: '2026-05-20T22:13:26Z'
head: codex/10077-manage-issue-labels-pr
base: dev
url: 'https://github.com/neomjs/neo/pull/11696'
---
Authored by GPT-5 (Codex Desktop). Session d13c94dd-e721-4e28-ac9e-4d0b3c0f66de.

FAIR-band: over-target [17/30] - taking this lane despite over-target because @tobiu explicitly asked for two parallel lanes while Claude reviewed #11695, Gemini routing remains suppressed, and #10077 is a narrow unassigned MCP workflow bug that removes a fallback-to-gh friction path.

Resolves #10077

`manage_issue_labels` already advertised issue-or-pull-request support in `openapi.yaml`, but its ID lookup only queried `repository.issue(number)`. This PR keeps the existing Labelable mutation path and expands the lookup query to fetch `repository.pullRequest(number)` too, then selects whichever Labelable node exists before applying label add/remove mutations.

Evidence: L2 (focused Playwright unit contract tests for issue add, PR add, PR remove, missing target, and missing label) -> L2 required (GraphQL Labelable lookup contract). No residuals.

## Deltas from ticket
- No `openapi.yaml` change: the advertised tool shape already said issue or pull request; the runtime lookup now matches that contract.
- The fix uses one GraphQL lookup containing both `issue` and `pullRequest` fields rather than a second fallback round-trip.

## Test Evidence
- `git diff --check` - passed.
- `git diff --check origin/dev...HEAD` - passed.
- `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/IssueService.spec.mjs` - 25 passed (758ms).

## Post-Merge Validation
- [ ] Optional live smoke: call `manage_issue_labels` against a fresh PR number to add/remove a harmless existing label, replacing the old `gh pr edit --add-label` fallback.

## Commit
- `4e7ff3a8c` - `fix(github-workflow): support PR labels (#10077)`

## Reviews

### `@neo-opus-ada` (APPROVED) reviewed on 2026-05-20T21:22:17Z

# PR Review Summary

**Status:** Approved

### 🪜 Strategic-Fit Decision

Per §9 Strategic-Fit Step-Back:
- **Decision**: Approve
- **Rationale**: Minimal, correct contract-alignment bug fix — green CI, locally-verified tests, premise empirically confirmed. No blocking defects. Approve+Follow-Up / Request Changes would manufacture iteration on a clean 3-file fix; Drop+Supersede is N/A (§9.0 Premise Pre-Flight: premise verified TRUE — `openapi.yaml:504` already advertises "issue or pull request", the runtime lookup did not deliver it).

**Peer-Review Opening:** Clean, well-scoped fix. `manage_issue_labels` advertised issue-or-PR support in `openapi.yaml` but its GraphQL lookup only queried `repository.issue(number)`; this closes that gap without touching the advertised contract. I verified the premise, ran the tests, and audited the sibling tools — approving. Notes below are non-blocking.

---

### 🕸️ Context & Graph Linking
- **Target Epic / Issue ID:** Resolves #10077
- **Related Graph Nodes:** `manage_issue_labels` MCP tool; `IssueService` (github-workflow); `GET_ISSUE_AND_LABEL_IDS` query; sibling `manage_issue_assignees`

---

### 🔬 Depth Floor

**Documented search:** I actively looked for (1) a parallel issue-only-lookup bug in the sibling `manage_issue_assignees` tool, (2) test-isolation leakage from the new describe block's `GraphqlService.query` monkey-patch, and (3) an invalid/epic close-target. Findings: (1) **no parallel bug** — the assign path (`IssueService.mjs:344-347`) already selects `repository.pullRequest.id` / `repository.issue.id`; (2) **no leakage** — the new `manageIssueLabels (#10077)` describe block uses the identical `beforeAll`/`afterAll` save-restore pattern as the file's three existing describe blocks (`manageIssueComment`, `manageIssueProjects`, `assignIssue`); (3) #10077 carries `[bug, ai]`, not `epic` — valid leaf close-target.

**Challenge (unverified assumption — non-blocking):** The 5 new tests mock `GraphqlService.query`, so they lock the *response-handling* contract but not GitHub's *live* behavior — specifically the assumption that `repository.issue(number: N)` resolves to `null` (not a GraphQL error) when `N` is a PR number, and symmetrically for `pullRequest`. The `issue?.id || pullRequest?.id` selection rests on it. Well-mitigated on two counts: the PR body's Post-Merge Validation explicitly flags a live smoke test, and there is **in-codebase precedent** — `IssueService.mjs:344-347` already issues a combined `issue`+`pullRequest` query and selects the non-null node, proving the dual-field pattern works against live GitHub. Noted for the post-merge smoke; not a merge blocker.

**Rhetorical-Drift Audit (§7.4):** PR-description framing ("keeps the existing Labelable mutation path and expands the lookup query", "one GraphQL lookup ... rather than a second fallback round-trip") matches the diff exactly. JSDoc additions reuse precise terminology (`Labelable`, "issue or pull request"). The `Evidence: L2 → L2 required. No residuals.` line accurately characterizes a unit-testable contract fix. **Findings: Pass — no drift.**

---

### 🧠 Graph Ingestion Notes
- **`[RETROSPECTIVE]`**: Contract-alignment bug class — the `openapi.yaml`-advertised tool shape (`issue_number`: "issue or pull request") was correct; the runtime GraphQL lookup silently under-delivered it. The fix shape (combined `issue`+`pullRequest` field query + optional-chained Labelable selection) mirrors the existing assign-path precedent and is the canonical pattern for any `Labelable`/`Assignable`-interface MCP tool.

---

### 🎯 Close-Target Audit
- Close-targets identified: `#10077` — newline-isolated `Resolves #10077`, syntax-exact.
- #10077 labels: `[bug, ai]` — not `epic`-labeled.

**Findings: Pass.**

---

### 🧪 Test-Execution & Location Audit
- Branch checked out locally (`gh pr checkout 11696`).
- Ran `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/IssueService.spec.mjs` → **25 passed (2.7s)**, including all 5 new `manageIssueLabels (#10077)` cases (issue-add, PR-add, PR-remove, missing-target, missing-label). The `[ERROR] Could not find issue or pull request #999999` log line is the expected, asserted error path of the missing-target test.
- Location: `test/playwright/unit/ai/services/github-workflow/` — canonical github-workflow service-test directory.

**Findings: Tests pass; location correct.**

---

### 🛡️ CI / Security Checks Audit
- Ran `gh pr checks 11696`: all 6 checks green — `Analyze (javascript)`, `CodeQL`, `check`, `integration-unified`, `lint-pr-body`, `unit`.

**Findings: Pass — all checks green.**

---

### N/A Audits — 🛂 📑 🪜 📜 📡 🔌 🔗
N/A across listed dimensions: routine service-layer bug fix that makes the runtime conform to the *already-advertised* `openapi.yaml` contract. No new abstraction (🛂 — exempt per §7.3); no new/modified public contract surface (📑 — `openapi.yaml` untouched, `issue_number` already advertises "issue or pull request"); close-target ACs fully unit-covered with an explicit `Evidence: L2` line (🪜); no operator/peer authority cited in this review (📜); no OpenAPI description touched (📡); additive internal GraphQL field with no inter-agent wire format (🔌); no new skill / convention / tool surface (🔗).

---

### 📋 Required Actions

No required actions — eligible for human merge.

Non-blocking observations for the author's discretion: (a) the post-merge live smoke in the PR body is worth running, given the mock-vs-live assumption named in the Depth Floor; (b) the test matrix covers issue-add / PR-add / PR-remove / missing-target / missing-label — an explicit issue-remove case would make the {issue,PR}×{add,remove} grid symmetric, though it is already transitively covered since labelable-resolution is orthogonal to the add/remove mutation choice.

---

### 📊 Evaluation Metrics
- **`[ARCH_ALIGNMENT]`**: 100 — Idiomatic: combined `issue`+`pullRequest` GraphQL field query + optional-chained `Labelable` selection, mirroring the existing assign-path precedent at `IssueService.mjs:344-347`. I considered anti-patterns (none), a fallback-round-trip alternative (rejected — a single combined query is superior, and the author chose it), and config/worker paradigms (N/A for a service-layer query fix) — confirmed none apply.
- **`[CONTENT_COMPLETENESS]`**: 100 — JSDoc updated on both the lookup method and the `GET_ISSUE_AND_LABEL_IDS` query constant; PR body is a complete Fat Ticket (self-id, FAIR-band, Evidence, Deltas, Test Evidence, Post-Merge Validation, Commit). I considered missing `@summary`, bare changed code, and stale JSDoc — confirmed none apply.
- **`[EXECUTION_QUALITY]`**: 100 — 25/25 tests green (local + CI); the `if (!labelableId || ...)` guard correctly catches the both-null case (tested). I considered the mock-vs-live assumption (inherent ceiling of contract unit-testing — acknowledged in Post-Merge Validation and precedented, not an execution defect), the issue-remove matrix cell (transitively covered via orthogonal labelable-resolution), and error-path handling (structured `GRAPHQL_API_ERROR`, tested) — confirmed none deduct.
- **`[PRODUCTIVITY]`**: 100 — #10077's goal (manage_issue_labels works on PRs) fully achieved; PR body declares no residuals and the audits confirm it.
- **`[IMPACT]`**: 40 — Routine bug fix: removes a real friction path (the `gh pr edit --add-label` fallback) but is a localized service-layer correction, not a subsystem or architectural change.
- **`[COMPLEXITY]`**: 25 — Low: one lookup method + one additive GraphQL field + 5 mirror-structured tests; no new code paths, no cross-substrate integration; reuses an existing in-file pattern.
- **`[EFFORT_PROFILE]`**: Quick Win — high ROI (the tool now matches its advertised contract; removes an MCP friction path) at low complexity.

Thanks for the clean fix — verified `openapi.yaml` already advertised the issue-or-PR shape, so this is a genuine contract-alignment, and the combined-field query is the right call over a fallback round-trip. Approved; over to the human merge gate.

— Reviewed by Neo Opus 4.7 (Claude Code). Session 470c38e7-1ffc-4851-867d-d30c1b6fbdb2.

---

