Skip to content

Commit 16fc6fd

Browse files
authored
Merge pull request #178 from maystudios/worktree-agent-a9ba34bf
fix: enforce spec compliance in GitHub module
2 parents 6b993ba + a035b3c commit 16fc6fd

7 files changed

Lines changed: 148 additions & 26 deletions

File tree

packages/cli/src/github/comments.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,16 @@ export function parseIssueState(body: string): MaxsimIssueState | null {
7474
};
7575
}
7676

77+
const VALID_COMMENT_TYPES = new Set(['plan', 'research', 'context', 'progress', 'verification', 'summary', 'error', 'escalation', 'handoff', 'user-intent', 'phase-complete', 'checkpoint'] as const);
78+
7779
/** Extract comment metadata from the first line HTML comment. */
7880
export function parseCommentMeta(body: string): MaxsimCommentMeta | null {
7981
const match = body.match(COMMENT_META_REGEX);
8082
if (!match) return null;
8183

82-
const type = match[1] as MaxsimCommentMeta['type'];
84+
const type = match[1];
85+
if (!(VALID_COMMENT_TYPES as ReadonlySet<string>).has(type)) return null;
86+
8387
const attrs: Record<string, unknown> = { type };
8488

8589
// Parse space-separated key=value pairs

packages/cli/src/github/discussions.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,19 @@ export function listDiscussions(
162162
}
163163

164164
const hasCategory = categoryId !== undefined;
165-
const query = `
166-
query($owner: String!, $repo: String!, $first: Int!${hasCategory ? ', $categoryId: ID!' : ''}) {
165+
166+
const allNodes: RawDiscussion[] = [];
167+
let hasNextPage = true;
168+
let endCursor: string | null = null;
169+
170+
while (hasNextPage) {
171+
const afterClause = endCursor ? `, after: $after` : '';
172+
const afterVar = endCursor ? ', $after: String!' : '';
173+
174+
const query = `
175+
query($owner: String!, $repo: String!, $first: Int!${hasCategory ? ', $categoryId: ID!' : ''}${afterVar}) {
167176
repository(owner: $owner, name: $repo) {
168-
discussions(first: $first${hasCategory ? ', categoryId: $categoryId' : ''}) {
177+
discussions(first: $first${hasCategory ? ', categoryId: $categoryId' : ''}${afterClause}) {
169178
nodes {
170179
number
171180
id
@@ -176,31 +185,54 @@ export function listDiscussions(
176185
createdAt
177186
updatedAt
178187
}
188+
pageInfo {
189+
hasNextPage
190+
endCursor
191+
}
179192
}
180193
}
181194
}
182195
`.trim();
183196

184-
const args = [
185-
'api', 'graphql',
186-
'-f', `query=${query}`,
187-
'-f', `owner=${owner}`,
188-
'-f', `repo=${repoName}`,
189-
'-F', `first=${first}`,
190-
];
197+
const args = [
198+
'api', 'graphql',
199+
'-f', `query=${query}`,
200+
'-f', `owner=${owner}`,
201+
'-f', `repo=${repoName}`,
202+
'-F', `first=${first}`,
203+
];
191204

192-
if (hasCategory) {
193-
args.push('-f', `categoryId=${categoryId}`);
194-
}
205+
if (hasCategory) {
206+
args.push('-f', `categoryId=${categoryId}`);
207+
}
195208

196-
const result = ghJson<{
197-
data: { repository: { discussions: { nodes: RawDiscussion[] } } };
198-
}>(args);
209+
if (endCursor) {
210+
args.push('-f', `after=${endCursor}`);
211+
}
199212

200-
if (!result.ok) return result;
213+
const result = ghJson<{
214+
data: {
215+
repository: {
216+
discussions: {
217+
nodes: RawDiscussion[];
218+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
219+
};
220+
};
221+
};
222+
}>(args);
223+
224+
if (!result.ok) return result;
225+
226+
const discussions = result.data?.data?.repository?.discussions;
227+
const nodes = discussions?.nodes ?? [];
228+
allNodes.push(...nodes);
229+
230+
const pageInfo = discussions?.pageInfo;
231+
hasNextPage = pageInfo?.hasNextPage ?? false;
232+
endCursor = pageInfo?.endCursor ?? null;
233+
}
201234

202-
const nodes = result.data?.data?.repository?.discussions?.nodes ?? [];
203-
return { ok: true, data: nodes.map(mapDiscussion) };
235+
return { ok: true, data: allNodes.map(mapDiscussion) };
204236
}
205237

206238
export function getDiscussion(

packages/cli/src/github/projects.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,11 @@ export async function ensureProjectBoard(
257257
]);
258258

259259
if (!updateResult.ok) {
260-
console.warn(
261-
`Warning: Failed to add missing Status options (${missingColumns.map((c) => c.name).join(', ')}): ${updateResult.error}. ` +
262-
'You may need to add them manually via the GitHub Projects UI.',
263-
);
260+
return {
261+
ok: false,
262+
error: `Failed to add missing Status options (${missingColumns.map((c) => c.name).join(', ')}): ${updateResult.error}`,
263+
code: 'UNKNOWN',
264+
};
264265
}
265266
}
266267

packages/cli/src/github/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export interface MaxsimIssueState {
133133

134134
/** Structured comment metadata from HTML comment markers. */
135135
export interface MaxsimCommentMeta {
136-
type: 'plan' | 'research' | 'context' | 'progress' | 'verification' | 'summary' | 'error' | 'escalation' | 'handoff' | 'user-intent';
136+
type: 'plan' | 'research' | 'context' | 'progress' | 'verification' | 'summary' | 'error' | 'escalation' | 'handoff' | 'user-intent' | 'phase-complete' | 'checkpoint';
137137
phase?: number;
138138
task?: number;
139139
[key: string]: unknown;

packages/cli/tests/unit/comments.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ describe('parseCommentMeta', () => {
120120
it('returns null for non-maxsim comments', () => {
121121
expect(parseCommentMeta('Just a normal comment')).toBeNull();
122122
});
123+
124+
it('returns null for invalid comment types', () => {
125+
expect(parseCommentMeta('<!-- maxsim:type=INVALID_TYPE -->')).toBeNull();
126+
});
123127
});
124128

125129
describe('formatIssueMeta', () => {

packages/cli/tests/unit/github-discussions.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,16 @@ function createDiscussionResponse(discussion: typeof MOCK_DISCUSSION_RAW = MOCK_
119119
}
120120

121121
/** Build the GraphQL response envelope for a discussions list query. */
122-
function listDiscussionsResponse(discussions: typeof MOCK_DISCUSSION_RAW[] = [MOCK_DISCUSSION_RAW]): string {
122+
function listDiscussionsResponse(
123+
discussions: typeof MOCK_DISCUSSION_RAW[] = [MOCK_DISCUSSION_RAW],
124+
pageInfo: { hasNextPage: boolean; endCursor: string | null } = { hasNextPage: false, endCursor: null },
125+
): string {
123126
return JSON.stringify({
124127
data: {
125128
repository: {
126129
discussions: {
127130
nodes: discussions,
131+
pageInfo,
128132
},
129133
},
130134
},
@@ -322,6 +326,28 @@ describe('listDiscussions', () => {
322326
const ghArgs = graphqlCall?.[1] as string[];
323327
expect(ghArgs).toContain('owner=customowner');
324328
});
329+
330+
it('paginates through multiple pages and returns all discussions', () => {
331+
const disc1 = { ...MOCK_DISCUSSION_RAW, number: 1, id: 'D_1', title: 'Discussion 1' };
332+
const disc2 = { ...MOCK_DISCUSSION_RAW, number: 2, id: 'D_2', title: 'Discussion 2' };
333+
const disc3 = { ...MOCK_DISCUSSION_RAW, number: 3, id: 'D_3', title: 'Discussion 3' };
334+
335+
// First page: 2 discussions with hasNextPage=true
336+
// Second page: 1 discussion with hasNextPage=false
337+
setupExecMock(
338+
listDiscussionsResponse([disc1, disc2], { hasNextPage: true, endCursor: 'cursor_abc' }),
339+
listDiscussionsResponse([disc3], { hasNextPage: false, endCursor: null }),
340+
);
341+
342+
const result = listDiscussions();
343+
344+
expect(result.ok).toBe(true);
345+
if (!result.ok) return;
346+
expect(result.data).toHaveLength(3);
347+
expect(result.data[0].number).toBe(1);
348+
expect(result.data[1].number).toBe(2);
349+
expect(result.data[2].number).toBe(3);
350+
});
325351
});
326352

327353
// ── getDiscussion ───────────────────────────────────────────────────────────

packages/cli/tests/unit/github-projects.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,61 @@ describe('ensureProjectBoard', () => {
656656
expect(graphqlCalls.length).toBe(1);
657657
});
658658

659+
it('returns ok: false when GraphQL mutation to add missing Status options fails', async () => {
660+
// Status field exists but is missing columns, and the GraphQL update fails.
661+
// We need a custom mock because setupExecMock catches all `gh api` calls
662+
// as getRepoInfo type-detection. Here we differentiate `gh api graphql`
663+
// (the mutation) from `gh api /users/...` (the type check).
664+
const partialFields = {
665+
fields: [
666+
{
667+
id: 'PVTSSF_status',
668+
name: 'Status',
669+
type: 'SINGLE_SELECT',
670+
options: [
671+
{ id: 'opt_backlog', name: 'Backlog', color: 'GRAY' },
672+
],
673+
},
674+
],
675+
};
676+
const projectList = { projects: [MOCK_PROJECT] };
677+
const ghResponses = [
678+
JSON.stringify(projectList),
679+
JSON.stringify(partialFields),
680+
];
681+
let callIndex = 0;
682+
execFileSyncMock.mockImplementation((_cmd: string, args?: readonly string[]) => {
683+
const argList = args ?? [];
684+
685+
// git calls used by getRepoInfo
686+
if (_cmd === 'git') {
687+
if (argList[0] === 'remote') return REPO_REMOTE_URL;
688+
}
689+
690+
// gh api /users/* call used to detect org/user type
691+
if (_cmd === 'gh' && argList[0] === 'api' && String(argList[1]).startsWith('/users/')) {
692+
return 'User';
693+
}
694+
695+
// gh api graphql — the mutation call should fail
696+
if (_cmd === 'gh' && argList[0] === 'api' && argList[1] === 'graphql') {
697+
throw new Error('gh: GraphQL mutation failed');
698+
}
699+
700+
// All other gh calls — dispatch from the queue
701+
const response = ghResponses[callIndex++];
702+
if (response instanceof Error) throw response;
703+
return response ?? '';
704+
});
705+
706+
const result = await ensureProjectBoard('My Board', 'testorg');
707+
708+
expect(result.ok).toBe(false);
709+
if (result.ok) return;
710+
expect(result.code).toBe('UNKNOWN');
711+
expect(result.error).toContain('Failed to add missing Status options');
712+
});
713+
659714
it('propagates error from findProject', async () => {
660715
setupExecMock(new Error('gh: 401 Unauthorized'));
661716

0 commit comments

Comments
 (0)