Repo-scoped assignable-user search for GitHub, GitLab, and Gitea, surfaced as
an assignee combobox in the metadata editor and @-mention autocomplete in
forge comment/reply/review surfaces.
- server: GET /api/{provider}/users/search (assignees / project members),
query + directory/override repo resolution, 429 -> 503, connected:false
degradation; GitLab assignee writes resolve login -> ID server-side
- wire: searchUsers (+ searchLabels/milestones/branches/tags) on the three
API clients with tests
- facade: userSearch capability (all three), searchUsers adapters,
mapGithubAssignee/mapGitlabMember/mapGiteaAssignee -> ForgeUser
- ui: ForgeLookupCombobox (keyboard nav, debounced 30s-TTL cache,
connected-only caching), ForgeMentionTextarea (@ token parsing, caret
restore), free-text fallback when lookup is unavailable; i18n in 12 locales
- extras sharing the same infrastructure: GitLab create-issue dialog and
label/milestone/branch/tag lookups in the metadata editor
875 lines
30 KiB
JavaScript
875 lines
30 KiB
JavaScript
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
|
|
// The GitHub route handlers lazy-import ./index.js (via getGitHubLibraries)
|
|
// for auth + repo resolution, so mocking the module is enough to exercise the
|
|
// read routes without real GitHub credentials or a temp data dir.
|
|
const mockState = vi.hoisted(() => ({
|
|
getOctokitOrNull: vi.fn(),
|
|
clearGitHubAuth: vi.fn(),
|
|
octokit: {
|
|
rest: {
|
|
pulls: {
|
|
listCommits: vi.fn(),
|
|
get: vi.fn(),
|
|
update: vi.fn(),
|
|
createReview: vi.fn(),
|
|
createReviewComment: vi.fn(),
|
|
listReviewComments: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
},
|
|
issues: {
|
|
listEventsForTimeline: vi.fn(),
|
|
createComment: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
listMilestonesForRepo: vi.fn(),
|
|
listComments: vi.fn(),
|
|
listAssignees: vi.fn(),
|
|
listLabelsForRepo: vi.fn(),
|
|
},
|
|
repos: {
|
|
listBranches: vi.fn(),
|
|
listTags: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('./index.js', () => ({
|
|
getOctokitOrNull: mockState.getOctokitOrNull,
|
|
clearGitHubAuth: mockState.clearGitHubAuth,
|
|
resolveGitHubRepoFromDirectory: vi.fn(async () => ({
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
})),
|
|
}));
|
|
|
|
const { registerGitHubRoutes } = await import('./routes.js');
|
|
|
|
const createApp = () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
registerGitHubRoutes(app);
|
|
return app;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockState.getOctokitOrNull.mockReset();
|
|
mockState.clearGitHubAuth.mockReset();
|
|
mockState.octokit.rest.pulls.listCommits.mockReset();
|
|
mockState.octokit.rest.pulls.get.mockReset();
|
|
mockState.octokit.rest.pulls.update.mockReset();
|
|
mockState.octokit.rest.pulls.createReview.mockReset();
|
|
mockState.octokit.rest.pulls.createReviewComment.mockReset();
|
|
mockState.octokit.rest.pulls.listReviewComments.mockReset();
|
|
mockState.octokit.rest.pulls.listFiles.mockReset();
|
|
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
|
|
mockState.octokit.rest.issues.createComment.mockReset();
|
|
mockState.octokit.rest.issues.create.mockReset();
|
|
mockState.octokit.rest.issues.update.mockReset();
|
|
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
|
|
mockState.octokit.rest.issues.listComments.mockReset();
|
|
mockState.octokit.rest.issues.listAssignees.mockReset();
|
|
mockState.octokit.rest.issues.listLabelsForRepo.mockReset();
|
|
mockState.octokit.rest.repos.listBranches.mockReset();
|
|
mockState.octokit.rest.repos.listTags.mockReset();
|
|
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
|
|
});
|
|
|
|
describe('GitHub pull request enrichment routes', () => {
|
|
test('pulls/commits maps commits with shortSha and summary', async () => {
|
|
mockState.octokit.rest.pulls.listCommits.mockResolvedValue({
|
|
data: [
|
|
{
|
|
sha: 'abc123def4567890',
|
|
commit: {
|
|
message: 'Add the API\n\nAdds the public API',
|
|
committer: { date: '2026-01-01T10:00:00Z' },
|
|
},
|
|
author: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
|
|
committer: null,
|
|
parents: [{ sha: 'parent-one' }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo' },
|
|
commits: [
|
|
{
|
|
sha: 'abc123def4567890',
|
|
shortSha: 'abc123d',
|
|
message: 'Add the API\n\nAdds the public API',
|
|
summary: 'Add the API',
|
|
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
|
|
committer: null,
|
|
committedAt: '2026-01-01T10:00:00Z',
|
|
parents: ['parent-one'],
|
|
},
|
|
],
|
|
});
|
|
expect(mockState.octokit.rest.pulls.listCommits).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
per_page: 100,
|
|
});
|
|
});
|
|
|
|
test('pulls/timeline maps timeline events with lowercased types', async () => {
|
|
mockState.octokit.rest.issues.listEventsForTimeline.mockResolvedValue({
|
|
data: [
|
|
{ id: 1, event: 'committed', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-01T10:00:00Z', commit_id: 'abc123def4567890' },
|
|
{ id: 2, event: 'CLOSED', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-02T10:00:00Z' },
|
|
{ id: 3, event: 'reviewed', actor: null, created_at: '2026-01-03T10:00:00Z', body: 'LGTM' },
|
|
],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/pulls/timeline?directory=%2Ftmp%2Fwork&number=9');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
events: [
|
|
{ id: '1', type: 'committed', author: { login: 'alice', id: 42 }, createdAt: '2026-01-01T10:00:00Z', commitSha: 'abc123def4567890' },
|
|
{ id: '2', type: 'closed', author: { login: 'alice', id: 42 } },
|
|
{ id: '3', type: 'reviewed', author: null, body: 'LGTM' },
|
|
],
|
|
});
|
|
expect(mockState.octokit.rest.issues.listEventsForTimeline).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
issue_number: 9,
|
|
per_page: 100,
|
|
});
|
|
});
|
|
|
|
test('pulls/commits returns connected:false when not authenticated', async () => {
|
|
mockState.getOctokitOrNull.mockImplementation(() => null);
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ connected: false });
|
|
});
|
|
|
|
test('pulls/commits requires directory and number', async () => {
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'directory and number are required' });
|
|
});
|
|
});
|
|
|
|
describe('GitHub write routes', () => {
|
|
test('issues/comment creates a comment and returns the envelope', async () => {
|
|
mockState.octokit.rest.issues.createComment.mockResolvedValue({
|
|
data: {
|
|
id: 1001,
|
|
html_url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
|
|
body: 'Hello',
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-01T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/comment')
|
|
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo' },
|
|
comment: {
|
|
id: 1001,
|
|
url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
|
|
body: 'Hello',
|
|
createdAt: '2026-01-01T10:00:00Z',
|
|
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
|
|
},
|
|
});
|
|
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
issue_number: 7,
|
|
body: 'Hello',
|
|
});
|
|
});
|
|
|
|
test('issues/comment returns connected:false when not authenticated', async () => {
|
|
mockState.getOctokitOrNull.mockImplementation(() => null);
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/comment')
|
|
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ connected: false });
|
|
});
|
|
|
|
test('issues/comment requires directory, number, and body', async () => {
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/comment')
|
|
.send({ directory: '/tmp/work', number: 7 });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'directory, number, body are required' });
|
|
});
|
|
|
|
test('issues/update passes state and labels through', async () => {
|
|
mockState.octokit.rest.issues.update.mockResolvedValue({
|
|
data: {
|
|
number: 7,
|
|
title: 'Bug',
|
|
body: 'desc',
|
|
html_url: 'https://github.com/owner/repo/issues/7',
|
|
state: 'closed',
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-02T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
assignees: [{ login: 'bob', id: 43, avatar_url: 'u' }],
|
|
milestone: { title: 'v1.0', state: 'open' },
|
|
comments: 3,
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.patch('/api/github/issues/update')
|
|
.send({ directory: '/tmp/work', number: 7, state: 'closed', labels: ['bug'] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo' },
|
|
issue: {
|
|
number: 7,
|
|
title: 'Bug',
|
|
state: 'closed',
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
assignees: [{ login: 'bob', id: 43 }],
|
|
milestone: { title: 'v1.0', state: 'open' },
|
|
commentsCount: 3,
|
|
},
|
|
});
|
|
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
issue_number: 7,
|
|
state: 'closed',
|
|
labels: ['bug'],
|
|
});
|
|
});
|
|
|
|
test('issues/update resolves milestone title to a number (case-insensitive)', async () => {
|
|
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
|
|
data: [{ number: 5, title: 'v1.0', state: 'open' }],
|
|
});
|
|
mockState.octokit.rest.issues.update.mockResolvedValue({
|
|
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.patch('/api/github/issues/update')
|
|
.send({ directory: '/tmp/work', number: 7, milestone: 'V1.0' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
state: 'all',
|
|
per_page: 100,
|
|
});
|
|
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ milestone: 5 })
|
|
);
|
|
});
|
|
|
|
test('issues/update returns 400 when the milestone title matches nothing', async () => {
|
|
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.patch('/api/github/issues/update')
|
|
.send({ directory: '/tmp/work', number: 7, milestone: 'nope' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'Milestone not found' });
|
|
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('issues/update passes milestone null through to clear it', async () => {
|
|
mockState.octokit.rest.issues.update.mockResolvedValue({
|
|
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.patch('/api/github/issues/update')
|
|
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockState.octokit.rest.issues.listMilestonesForRepo).not.toHaveBeenCalled();
|
|
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ milestone: null })
|
|
);
|
|
});
|
|
|
|
test('pulls/comment posts to the PR issue thread', async () => {
|
|
mockState.octokit.rest.issues.createComment.mockResolvedValue({
|
|
data: {
|
|
id: 2001,
|
|
html_url: 'https://github.com/owner/repo/pull/9#issuecomment-2001',
|
|
body: 'Thanks',
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-01T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pulls/comment')
|
|
.send({ directory: '/tmp/work', number: 9, body: 'Thanks' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
comment: { id: 2001, body: 'Thanks', author: { login: 'alice', id: 42 } },
|
|
});
|
|
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
issue_number: 9,
|
|
body: 'Thanks',
|
|
});
|
|
});
|
|
|
|
test('pulls/review-comment creates a reply when inReplyToId is provided', async () => {
|
|
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
|
|
data: {
|
|
id: 3001,
|
|
html_url: 'u',
|
|
body: 'reply',
|
|
path: 'src/a.ts',
|
|
line: 3,
|
|
position: null,
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-01T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pulls/review-comment')
|
|
.send({ directory: '/tmp/work', number: 9, body: 'reply', inReplyToId: 2999 });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
comment: { id: 3001, body: 'reply', path: 'src/a.ts', line: 3 },
|
|
});
|
|
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
body: 'reply',
|
|
in_reply_to_id: 2999,
|
|
});
|
|
expect(mockState.octokit.rest.pulls.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('pulls/review-comment resolves the PR head sha for a new inline comment', async () => {
|
|
mockState.octokit.rest.pulls.get.mockResolvedValue({ data: { head: { sha: 'abc123def4567890' } } });
|
|
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
|
|
data: {
|
|
id: 3002,
|
|
html_url: 'u',
|
|
body: 'nit',
|
|
path: 'src/a.ts',
|
|
line: 5,
|
|
position: 1,
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-01T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pulls/review-comment')
|
|
.send({ directory: '/tmp/work', number: 9, body: 'nit', path: 'src/a.ts', line: 5 });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
comment: { id: 3002, body: 'nit', path: 'src/a.ts', line: 5, position: 1 },
|
|
});
|
|
expect(mockState.octokit.rest.pulls.get).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
});
|
|
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
body: 'nit',
|
|
commit_id: 'abc123def4567890',
|
|
path: 'src/a.ts',
|
|
line: 5,
|
|
});
|
|
});
|
|
|
|
test('pulls/review-comment requires path and line for a new inline comment', async () => {
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pulls/review-comment')
|
|
.send({ directory: '/tmp/work', number: 9, body: 'nit' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'path and line are required for a new review comment' });
|
|
expect(mockState.octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('pulls/review maps the submitted review and invalidates the PR context cache', async () => {
|
|
mockState.octokit.rest.pulls.get.mockResolvedValue({
|
|
data: {
|
|
number: 9,
|
|
title: 'T',
|
|
body: '',
|
|
html_url: 'u',
|
|
state: 'open',
|
|
draft: false,
|
|
base: { ref: 'main' },
|
|
head: { ref: 'feature' },
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
},
|
|
});
|
|
mockState.octokit.rest.issues.listComments.mockResolvedValue({ data: [] });
|
|
mockState.octokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] });
|
|
mockState.octokit.rest.pulls.listFiles.mockResolvedValue({ data: [] });
|
|
|
|
const app = createApp();
|
|
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
|
|
const pullsGetCallsAfterContext = mockState.octokit.rest.pulls.get.mock.calls.length;
|
|
|
|
mockState.octokit.rest.pulls.createReview.mockResolvedValue({
|
|
data: {
|
|
id: 4001,
|
|
state: 'APPROVED',
|
|
submitted_at: '2026-01-01T10:00:00Z',
|
|
body: 'LGTM',
|
|
commit_id: 'abc123def4567890',
|
|
user: { login: 'alice', id: 42, avatar_url: 'u' },
|
|
},
|
|
});
|
|
|
|
const reviewResponse = await request(app)
|
|
.post('/api/github/pulls/review')
|
|
.send({ directory: '/tmp/work', number: 9, event: 'APPROVE', body: 'LGTM' });
|
|
|
|
expect(reviewResponse.status).toBe(200);
|
|
expect(reviewResponse.body).toMatchObject({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo' },
|
|
review: {
|
|
id: '4001',
|
|
state: 'APPROVED',
|
|
submittedAt: '2026-01-01T10:00:00Z',
|
|
body: 'LGTM',
|
|
commitSha: 'abc123def4567890',
|
|
author: { login: 'alice', id: 42 },
|
|
},
|
|
});
|
|
expect(mockState.octokit.rest.pulls.createReview).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
event: 'APPROVE',
|
|
body: 'LGTM',
|
|
});
|
|
|
|
// The PR context cache must have been invalidated: the next context fetch
|
|
// re-resolves the PR instead of serving the cached copy.
|
|
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
|
|
expect(mockState.octokit.rest.pulls.get.mock.calls.length).toBe(pullsGetCallsAfterContext + 1);
|
|
});
|
|
|
|
test('pulls/review requires directory, number, and event', async () => {
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pulls/review')
|
|
.send({ directory: '/tmp/work', number: 9 });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'directory, number, event are required' });
|
|
});
|
|
|
|
test('pr/update branches to issues.update and applies draft via pulls.update', async () => {
|
|
mockState.octokit.rest.issues.update.mockResolvedValue({
|
|
data: {
|
|
number: 9,
|
|
title: 'T',
|
|
body: '',
|
|
html_url: 'u',
|
|
state: 'open',
|
|
draft: false,
|
|
base: { ref: 'main' },
|
|
head: { ref: 'feature' },
|
|
mergeable: true,
|
|
mergeable_state: 'clean',
|
|
user: null,
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
assignees: [],
|
|
milestone: null,
|
|
},
|
|
});
|
|
mockState.octokit.rest.pulls.update.mockResolvedValue({
|
|
data: {
|
|
number: 9,
|
|
title: 'T',
|
|
body: '',
|
|
html_url: 'u',
|
|
state: 'open',
|
|
draft: true,
|
|
base: { ref: 'main' },
|
|
head: { ref: 'feature' },
|
|
mergeable: true,
|
|
mergeable_state: 'clean',
|
|
user: null,
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
assignees: [],
|
|
milestone: null,
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pr/update')
|
|
.send({
|
|
directory: '/tmp/work',
|
|
number: 9,
|
|
title: 'T',
|
|
state: 'closed',
|
|
draft: true,
|
|
labels: ['bug'],
|
|
assignees: ['alice'],
|
|
milestone: null,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({ number: 9, state: 'open', draft: true });
|
|
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
issue_number: 9,
|
|
state: 'closed',
|
|
labels: ['bug'],
|
|
assignees: ['alice'],
|
|
milestone: null,
|
|
})
|
|
);
|
|
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
draft: true,
|
|
});
|
|
});
|
|
|
|
test('pr/update keeps title/body on pulls.update when no extended fields are present', async () => {
|
|
mockState.octokit.rest.pulls.update.mockResolvedValue({
|
|
data: {
|
|
number: 9,
|
|
title: 'New title',
|
|
body: '',
|
|
html_url: 'u',
|
|
state: 'open',
|
|
draft: false,
|
|
base: { ref: 'main' },
|
|
head: { ref: 'feature' },
|
|
mergeable: true,
|
|
mergeable_state: 'clean',
|
|
user: null,
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pr/update')
|
|
.send({ directory: '/tmp/work', number: 9, title: 'New title' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
pull_number: 9,
|
|
title: 'New title',
|
|
});
|
|
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('pr/update returns 400 when the milestone title matches nothing', async () => {
|
|
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/pr/update')
|
|
.send({ directory: '/tmp/work', number: 9, title: 'T', milestone: 'nope' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'Milestone not found' });
|
|
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GitHub issues/create route', () => {
|
|
test('issues/create calls issues.create and returns the created issue', async () => {
|
|
mockState.octokit.rest.issues.create.mockResolvedValue({
|
|
data: {
|
|
number: 12,
|
|
title: 'Add feature',
|
|
html_url: 'https://github.com/owner/repo/issues/12',
|
|
state: 'open',
|
|
body: 'The body',
|
|
created_at: '2026-01-01T10:00:00Z',
|
|
updated_at: '2026-01-01T10:00:00Z',
|
|
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/create')
|
|
.send({ directory: '/tmp/work', title: 'Add feature', body: 'The body', labels: ['bug'] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo' },
|
|
issue: {
|
|
number: 12,
|
|
title: 'Add feature',
|
|
url: 'https://github.com/owner/repo/issues/12',
|
|
state: 'open',
|
|
body: 'The body',
|
|
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
},
|
|
});
|
|
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
title: 'Add feature',
|
|
body: 'The body',
|
|
labels: ['bug'],
|
|
});
|
|
});
|
|
|
|
test('issues/create omits body and labels when not provided', async () => {
|
|
mockState.octokit.rest.issues.create.mockResolvedValue({
|
|
data: {
|
|
number: 13,
|
|
title: 'Title only',
|
|
html_url: 'https://github.com/owner/repo/issues/13',
|
|
state: 'open',
|
|
user: null,
|
|
},
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/create')
|
|
.send({ directory: '/tmp/work', title: 'Title only' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.issue.title).toBe('Title only');
|
|
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
title: 'Title only',
|
|
});
|
|
});
|
|
|
|
test('issues/create returns connected:false when not authenticated', async () => {
|
|
mockState.getOctokitOrNull.mockImplementation(() => null);
|
|
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/create')
|
|
.send({ directory: '/tmp/work', title: 'Hi' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ connected: false });
|
|
expect(mockState.octokit.rest.issues.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('issues/create requires directory and title', async () => {
|
|
const app = createApp();
|
|
const response = await request(app)
|
|
.post('/api/github/issues/create')
|
|
.send({ directory: '/tmp/work' });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'directory and title are required' });
|
|
});
|
|
});
|
|
|
|
describe('GitHub rich lookup routes', () => {
|
|
describe('users/search', () => {
|
|
test('maps assignable users and filters by query', async () => {
|
|
mockState.octokit.rest.issues.listAssignees.mockResolvedValue({
|
|
data: [
|
|
{ login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
|
|
{ login: 'bob', id: 43, avatar_url: 'https://avatars.githubusercontent.com/u/43' },
|
|
{ login: 'carol', id: 44, avatar_url: null, name: 'Carol Coder' },
|
|
],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
users: [{ login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' }],
|
|
});
|
|
expect(mockState.octokit.rest.issues.listAssignees).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
per_page: 100,
|
|
});
|
|
});
|
|
|
|
test('returns connected:false when not authenticated', async () => {
|
|
mockState.getOctokitOrNull.mockImplementation(() => null);
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ connected: false, users: [] });
|
|
});
|
|
});
|
|
|
|
describe('labels/search', () => {
|
|
test('maps repo labels and filters by query', async () => {
|
|
mockState.octokit.rest.issues.listLabelsForRepo.mockResolvedValue({
|
|
data: [
|
|
{ name: 'bug', color: 'd73a4a' },
|
|
{ name: 'feature', color: '0e8a16' },
|
|
],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/labels/search?directory=%2Ftmp%2Fwork&query=bug');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
labels: [{ name: 'bug', color: 'd73a4a' }],
|
|
});
|
|
expect(mockState.octokit.rest.issues.listLabelsForRepo).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
per_page: 100,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('milestones/search', () => {
|
|
test('maps milestone titles and states', async () => {
|
|
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
|
|
data: [
|
|
{ title: 'v1.0', state: 'open' },
|
|
{ title: 'v2.0', state: 'closed' },
|
|
],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
milestones: [{ title: 'v1.0', state: 'open' }],
|
|
});
|
|
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
state: 'all',
|
|
per_page: 100,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('branches/search', () => {
|
|
test('aggregates branch names across pages and filters by query', async () => {
|
|
mockState.octokit.rest.repos.listBranches
|
|
.mockResolvedValueOnce({ data: [{ name: 'main' }, { name: 'feat/x' }] })
|
|
.mockResolvedValueOnce({ data: [] });
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/branches/search?directory=%2Ftmp%2Fwork&query=main');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
branches: ['main'],
|
|
});
|
|
expect(mockState.octokit.rest.repos.listBranches).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
per_page: 100,
|
|
page: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tags/search', () => {
|
|
test('maps tag names and filters by query', async () => {
|
|
mockState.octokit.rest.repos.listTags.mockResolvedValue({
|
|
data: [{ name: 'v1.0.0' }, { name: 'v1.1.0' }],
|
|
});
|
|
|
|
const app = createApp();
|
|
const response = await request(app).get('/api/github/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
connected: true,
|
|
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
|
|
tags: ['v1.0.0'],
|
|
});
|
|
expect(mockState.octokit.rest.repos.listTags).toHaveBeenCalledWith({
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
per_page: 100,
|
|
});
|
|
});
|
|
});
|
|
});
|