Skip to content

Commit 7f720ed

Browse files
committed
feat(mcp/github-workflow): add list_issues tool, OpenAPI path/schemas and verification tests
Closes: #7663 - Feat: Implement GitHub API-based 'list_issues' tool Includes: - IssueService.listIssues implementation - Registered list_issues in toolService - OpenAPI: GET /issues + IssueListResponse schema - lightweight file-based verification tests - HealthService robustness fix for gh stdout/stderr
1 parent b5616d7 commit 7f720ed

6 files changed

Lines changed: 238 additions & 9 deletions

File tree

ai/mcp/server/github-workflow/openapi.yaml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,63 @@ paths:
596596
$ref: '#/components/schemas/ErrorResponse'
597597

598598
/issues:
599+
get:
600+
summary: List Issues
601+
operationId: list_issues
602+
x-pass-as-object: true
603+
x-annotations:
604+
readOnlyHint: true
605+
description: |
606+
Retrieves a list of issues from the repository. Supports basic filters such as `limit`, `state`, `labels`, and `assignee`.
607+
608+
**When to Use:**
609+
- To find open work items or review recent closed issues.
610+
- To look up issue numbers for use in other tools.
611+
tags: [Issues]
612+
parameters:
613+
- name: limit
614+
in: query
615+
required: false
616+
description: Maximum number of issues to return.
617+
schema:
618+
type: integer
619+
minimum: 1
620+
maximum: 100
621+
default: 30
622+
- name: state
623+
in: query
624+
required: false
625+
description: Filter issues by state.
626+
schema:
627+
type: string
628+
enum: [open, closed, all]
629+
default: open
630+
- name: labels
631+
in: query
632+
required: false
633+
description: Comma separated list of labels to filter by (all labels must be present on the issue).
634+
schema:
635+
type: string
636+
- name: assignee
637+
in: query
638+
required: false
639+
description: Filter issues by a single assignee login.
640+
schema:
641+
type: string
642+
responses:
643+
'200':
644+
description: A list of issues.
645+
content:
646+
application/json:
647+
schema:
648+
$ref: '#/components/schemas/IssueListResponse'
649+
'500':
650+
description: Internal server error, e.g., GraphQL failure.
651+
content:
652+
application/json:
653+
schema:
654+
$ref: '#/components/schemas/ErrorResponse'
655+
599656
post:
600657
summary: Create a new GitHub issue
601658
operationId: create_issue
@@ -1016,3 +1073,52 @@ components:
10161073
description: The permission level of the viewer.
10171074
enum: [ADMIN, MAINTAIN, WRITE, TRIAGE, READ]
10181075
example: WRITE
1076+
1077+
Issue:
1078+
type: object
1079+
properties:
1080+
number:
1081+
type: integer
1082+
example: 7608
1083+
title:
1084+
type: string
1085+
body:
1086+
type: string
1087+
state:
1088+
type: string
1089+
example: OPEN
1090+
author:
1091+
type: object
1092+
properties:
1093+
login:
1094+
type: string
1095+
url:
1096+
type: string
1097+
format: uri
1098+
labels:
1099+
type: array
1100+
items:
1101+
type: object
1102+
properties:
1103+
name:
1104+
type: string
1105+
assignees:
1106+
type: array
1107+
items:
1108+
type: object
1109+
properties:
1110+
login:
1111+
type: string
1112+
createdAt:
1113+
type: string
1114+
format: date-time
1115+
1116+
IssueListResponse:
1117+
type: object
1118+
properties:
1119+
count:
1120+
type: integer
1121+
issues:
1122+
type: array
1123+
items:
1124+
$ref: '#/components/schemas/Issue'

ai/mcp/server/github-workflow/services/HealthService.mjs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,15 @@ class HealthService extends Base {
8585
*/
8686
async #checkGhAuth() {
8787
try {
88-
const { stdout } = await execAsync('gh auth status');
88+
// Some platforms or gh versions may write status information to stderr
89+
// instead of stdout. Capture both and check the combined output.
90+
const { stdout, stderr } = await execAsync('gh auth status');
91+
const out = `${stdout || ''}\n${stderr || ''}`;
8992

9093
// The `gh auth status` command outputs information about the logged-in account.
9194
// We specifically check for "Logged in to github.com" to confirm the user
9295
// is authenticated to the correct GitHub instance (not enterprise).
93-
if (stdout.includes('Logged in to github.com')) {
96+
if (out.includes('Logged in to github.com')) {
9497
return { authenticated: true };
9598
} else {
9699
return {
@@ -99,12 +102,11 @@ class HealthService extends Base {
99102
};
100103
}
101104
} catch (e) {
102-
// The command failing typically means `gh` is not authenticated.
103-
// This is a common scenario when the user first installs `gh` or their
104-
// authentication token has expired.
105+
// The command failing typically means `gh` is not installed or not
106+
// available in PATH for child processes. Provide an actionable message.
105107
return {
106108
authenticated: false,
107-
error : 'GitHub CLI is not authenticated. Please run `gh auth login`.'
109+
error : 'GitHub CLI is not authenticated or not available. Please run `gh auth login`.'
108110
};
109111
}
110112
}
@@ -125,8 +127,10 @@ class HealthService extends Base {
125127
*/
126128
async #checkGhVersion() {
127129
try {
128-
const { stdout } = await execAsync('gh --version');
129-
const versionMatch = stdout.match(/gh version ([\d.]+)/);
130+
// Capture both stdout and stderr for robustness across environments
131+
const { stdout, stderr } = await execAsync('gh --version');
132+
const out = `${stdout || ''}\n${stderr || ''}`;
133+
const versionMatch = out.match(/gh version ([\d.]+)/);
130134

131135
if (versionMatch) {
132136
const currentVersion = versionMatch[1];

ai/mcp/server/github-workflow/services/IssueService.mjs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import logger from '../logger.mjs';
55
import {exec} from 'child_process';
66
import {promisify} from 'util';
77
import {spawn} from 'child_process';
8-
import {GET_ISSUE_AND_LABEL_IDS} from './queries/issueQueries.mjs';
8+
import {GET_ISSUE_AND_LABEL_IDS, FETCH_ISSUES_FOR_SYNC, DEFAULT_QUERY_LIMITS} from './queries/issueQueries.mjs';
99
import {ADD_LABELS, REMOVE_LABELS} from './queries/mutations.mjs';
1010
import RepositoryService from './RepositoryService.mjs';
1111

@@ -317,6 +317,77 @@ class IssueService extends Base {
317317
};
318318
}
319319
}
320+
321+
/**
322+
* Lists issues from the repository using the GraphQL API.
323+
* Supports basic pagination and state filtering. Label and assignee
324+
* filters are applied client-side to keep the GraphQL query simple and
325+
* compatible with the existing sync query.
326+
*
327+
* @param {object} options
328+
* @param {number} [options.limit=30]
329+
* @param {string} [options.state='open']
330+
* @param {string[]|string} [options.labels]
331+
* @param {string} [options.assignee]
332+
* @param {string} [options.cursor]
333+
* @returns {Promise<object>}
334+
*/
335+
async listIssues(options = {}) {
336+
const {
337+
limit = 30,
338+
state = 'open',
339+
labels = null,
340+
assignee = null,
341+
cursor = null
342+
} = options;
343+
344+
// normalize state to uppercase array (GraphQL expects IssueState enum values)
345+
const states = state ? (Array.isArray(state) ? state.map(s => s.toUpperCase()) : [state.toUpperCase()]) : undefined;
346+
347+
const variables = {
348+
owner : aiConfig.owner,
349+
repo : aiConfig.repo,
350+
limit,
351+
cursor,
352+
states,
353+
since : null,
354+
...DEFAULT_QUERY_LIMITS
355+
};
356+
357+
try {
358+
const data = await GraphqlService.query(FETCH_ISSUES_FOR_SYNC, variables);
359+
let issues = data.repository.issues.nodes || [];
360+
361+
// client-side label filtering if requested
362+
if (labels) {
363+
const labelList = Array.isArray(labels) ? labels : String(labels).split(',').map(s => s.trim()).filter(Boolean);
364+
issues = issues.filter(issue => {
365+
const issueLabels = (issue.labels && issue.labels.nodes || []).map(l => l.name);
366+
return labelList.every(l => issueLabels.includes(l));
367+
});
368+
}
369+
370+
// client-side assignee filtering if requested
371+
if (assignee) {
372+
issues = issues.filter(issue => {
373+
const assignees = (issue.assignees && issue.assignees.nodes || []).map(a => a.login);
374+
return assignees.includes(assignee);
375+
});
376+
}
377+
378+
return {
379+
count: issues.length,
380+
issues
381+
};
382+
} catch (error) {
383+
logger.error('Error fetching issues via GraphQL:', error);
384+
return {
385+
error : 'GraphQL API request failed',
386+
message: error.message,
387+
code : 'GRAPHQL_API_ERROR'
388+
};
389+
}
390+
}
320391
}
321392

322393
export default Neo.setupClass(IssueService);

ai/mcp/server/github-workflow/services/toolService.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const serviceMapping = {
2626
healthcheck : HealthService .healthcheck .bind(HealthService),
2727
list_labels : LabelService .listLabels .bind(LabelService),
2828
list_pull_requests : PullRequestService.listPullRequests .bind(PullRequestService),
29+
list_issues : IssueService .listIssues .bind(IssueService),
2930
remove_labels : IssueService .removeLabels .bind(IssueService),
3031
sync_all : SyncService .runFullSync .bind(SyncService),
3132
unassign_issue : IssueService .unassignIssue .bind(IssueService)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
const f = path.resolve('ai/mcp/server/github-workflow/openapi.yaml');
5+
if (!fs.existsSync(f)) {
6+
console.error('openapi.yaml not found at', f);
7+
process.exit(2);
8+
}
9+
const s = fs.readFileSync(f, 'utf8');
10+
11+
const hasPath = /(^|\n)\s*\/issues\s*:/m.test(s) || /\/issues\b/.test(s);
12+
const hasSchema = /IssueListResponse\b/.test(s) || /components:[\s\S]*IssueListResponse/m.test(s);
13+
14+
if (hasPath && hasSchema) {
15+
console.log('OK: openapi.yaml contains /issues path and IssueListResponse schema');
16+
process.exit(0);
17+
} else {
18+
console.error('FAIL: openapi.yaml missing /issues path or IssueListResponse schema');
19+
if (!hasPath) console.error('- /issues path not found');
20+
if (!hasSchema) console.error('- IssueListResponse schema not found');
21+
process.exit(1);
22+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
const f = path.resolve('ai/mcp/server/github-workflow/services/toolService.mjs');
5+
if (!fs.existsSync(f)) {
6+
console.error('toolService.mjs not found at', f);
7+
process.exit(2);
8+
}
9+
const s = fs.readFileSync(f, 'utf8');
10+
11+
// Try to extract the serviceMapping block roughly and detect keys
12+
const m = s.match(/serviceMapping\s*=\s*\{([\s\S]*?)\};?/m);
13+
if (!m) {
14+
console.error('serviceMapping block not found in toolService.mjs');
15+
process.exit(3);
16+
}
17+
const body = m[1];
18+
const keys = [...body.matchAll(/(?:['\"])?([a-zA-Z0-9_]+)(?:['\"])?\s*:/g)].map(x=>x[1]);
19+
if (keys.includes('list_issues')) {
20+
console.log('OK: list_issues registered in toolService');
21+
process.exit(0);
22+
} else {
23+
console.error('FAIL: list_issues not found in toolService mapping. Keys found:', keys.join(', '));
24+
process.exit(1);
25+
}

0 commit comments

Comments
 (0)