merge: resolve v1.21.0 conflicts with custom
This commit is contained in:
@@ -32,18 +32,30 @@
|
||||
|
||||
### Device flow
|
||||
|
||||
- `startDeviceFlow({ clientId, scope })`: request device code.
|
||||
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
|
||||
- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code.
|
||||
- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token.
|
||||
|
||||
### Octokit
|
||||
|
||||
- `getOctokitOrNull()`: current Octokit or `null`.
|
||||
- `getOctokitOrNull(directory?)`: current Octokit or `null`. When `directory` is provided the API base resolution is directory-aware (see "Per-project overrides" below); without it the global base URL is used.
|
||||
- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor.
|
||||
|
||||
### Repo
|
||||
|
||||
- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
|
||||
- `parseGitHubRemoteUrl(raw, options?)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`; `options.host` / `options.webOrigin` default to `github.com` / `https://github.com` and are used for self-hosted (Enterprise) remotes.
|
||||
- `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote.
|
||||
|
||||
## Git provider configuration
|
||||
|
||||
Per-provider settings come from `~/.config/openchamber/settings.json` under `gitProviders` (validated in `packages/web/server/lib/git-providers/config.js`, persisted via the settings GET/PUT routes). GitHub resolution:
|
||||
|
||||
- API base URL: configured `gitProviders.github.apiBaseUrl` -> default `https://api.github.com`. The configured value drives the Octokit `baseUrl` (`getOctokitOrNull`, device-flow account activation).
|
||||
- Device flow web origin: derived from the API base via `githubWebOriginFromApiBase` — the public host maps to `https://github.com`; an Enterprise base (`https://host/api/v3` or `https://host/api`) maps to `https://host`.
|
||||
|
||||
### Per-project overrides
|
||||
|
||||
API base resolution is directory-aware for project-scoped routes: `getOctokitOrNull(directory)` resolves the effective base via `getEffectiveProviderApiBaseUrl('github', directory)` (in `packages/web/server/lib/git-providers/project-config.js`), which prefers a per-project `gitProviders.github.apiBaseUrl` override (stored under `projects/<projectId>.json`) over the global `settings.json` value and the built-in default. Global routes (auth/status, auth/activate, me, repo/branches) and the device flow keep using the global base URL unchanged.
|
||||
|
||||
## Auth storage and config
|
||||
|
||||
- Auth storage: `~/.config/openchamber/github-auth.json`
|
||||
@@ -61,6 +73,24 @@
|
||||
- The route then enriches that result with checks, mergeability, and permission-related fields.
|
||||
- The client caches and shares the result between sidebar and Git view.
|
||||
|
||||
## Enrichment read APIs
|
||||
|
||||
- `GET /api/github/pulls/commits?directory&number&owner&repo` -> `{ connected, repo?, commits[] }` (via `octokit.rest.pulls.listCommits`, mapped to `{ sha, shortSha, message, summary, author, committer, committedAt, parents }`).
|
||||
- `GET /api/github/pulls/timeline?directory&number&owner&repo` -> `{ connected, repo?, events[] }` (via `octokit.rest.issues.listEventsForTimeline`, each event `{ id, type, author, createdAt, body, commitSha }` with the event name lowercased).
|
||||
- Both follow the `issues/comments` envelope pattern: unauthenticated -> `connected: false`, unresolvable repo -> `repo: null` with an empty list, `429` -> `503 { error: 'GitHub rate limited' }`, other provider `4xx` -> `502`.
|
||||
|
||||
## Write APIs
|
||||
|
||||
All write routes accept an optional `owner`/`repo` in the body to target a fork-network repo; otherwise the repo is resolved from `directory`. Unauthenticated -> `{ connected: false }`; `429` -> `503 { error: 'GitHub rate limited' }`; generic failures -> `500` with a generic error (raw upstream text is never leaked).
|
||||
|
||||
- `POST /api/github/issues/comment` — body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.issues.createComment`, mapped to `GitHubIssueComment`).
|
||||
- `POST /api/github/issues/create` — body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.create`; `labels` is a full-set list of names).
|
||||
- `PATCH /api/github/issues/update` — body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.update`; `labels`/`assignees` replace the full set, `milestone` is a title resolved to a milestone number — `400 { error: 'Milestone not found' }` when it matches nothing, `null` clears it). Also works for pull requests (PRs are issues), so it serves PR metadata/state changes too.
|
||||
- `POST /api/github/pulls/comment` — same input/result shape as `issues/comment`; posts to the PR's issue thread via `octokit.rest.issues.createComment`. Invalidates the PR context cache.
|
||||
- `POST /api/github/pulls/review-comment` — body `{ directory, number, body, inReplyToId?, path?, line?, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.pulls.createReviewComment`). With `inReplyToId` it is a reply; otherwise `path` + `line` are required and the PR head commit is resolved first. Invalidates the PR context cache.
|
||||
- `POST /api/github/pulls/review` — body `{ directory, number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?, owner?, repo? }` -> `{ connected, repo?, review? }` (via `octokit.rest.pulls.createReview`, mapped to `{ id, state, author, submittedAt, body, commitSha }`). Invalidates the PR context cache.
|
||||
- `POST /api/github/pr/update` — existing route extended with optional `state`, `draft`, `labels`, `assignees`, `milestone`. When any extended field is present it branches to `octokit.rest.issues.update` (milestone title -> number; `draft` applied separately via `octokit.rest.pulls.update`); title/body-only updates keep using `pulls.update`. Invalidates the PR context cache and the repo pulls cache.
|
||||
|
||||
## Consumers of PR data
|
||||
|
||||
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
const DEFAULT_WEB_ORIGIN = 'https://github.com';
|
||||
|
||||
const encodeForm = (params) => {
|
||||
const body = new URLSearchParams();
|
||||
@@ -32,16 +31,19 @@ async function postForm(url, params) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startDeviceFlow({ clientId, scope }) {
|
||||
return postForm(DEVICE_CODE_URL, {
|
||||
const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`;
|
||||
const accessTokenUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/oauth/access_token`;
|
||||
|
||||
export async function startDeviceFlow({ clientId, scope, webOrigin }) {
|
||||
return postForm(deviceCodeUrl(webOrigin), {
|
||||
client_id: clientId,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
export async function exchangeDeviceCode({ clientId, deviceCode }) {
|
||||
export async function exchangeDeviceCode({ clientId, deviceCode, webOrigin }) {
|
||||
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
|
||||
const payload = await postForm(ACCESS_TOKEN_URL, {
|
||||
const payload = await postForm(accessTokenUrl(webOrigin), {
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
|
||||
@@ -28,3 +28,8 @@ export {
|
||||
parseGitHubRemoteUrl,
|
||||
resolveGitHubRepoFromDirectory,
|
||||
} from './repo/index.js';
|
||||
|
||||
export {
|
||||
getProviderApiBaseUrl,
|
||||
githubWebOriginFromApiBase,
|
||||
} from '../git-providers/config.js';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
|
||||
import { getGhCliToken } from './gh-cli-credential.js';
|
||||
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
|
||||
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
|
||||
// which has no built-in timeout — without this, a stuck connection hangs until
|
||||
@@ -69,16 +71,20 @@ const createConditionalFetch = (token) => async (url, options = {}) => {
|
||||
};
|
||||
|
||||
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
|
||||
export function createOctokit(token) {
|
||||
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
|
||||
export function createOctokit(token, baseUrl) {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
request: { fetch: createConditionalFetch(token) },
|
||||
});
|
||||
}
|
||||
|
||||
export function getOctokitOrNull() {
|
||||
export function getOctokitOrNull(directory) {
|
||||
const auth = getGitHubAuth();
|
||||
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
|
||||
const token = isGhCliActive() ? ghToken || auth?.accessToken : auth?.accessToken || ghToken;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return createOctokit(token);
|
||||
return createOctokit(token, directory ? getEffectiveProviderApiBaseUrl('github', directory) : getProviderApiBaseUrl('github'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// getOctokitOrNull reads auth + config modules; mock them all so the base URL
|
||||
// resolution can be asserted without a real token, data dir, or Octokit client.
|
||||
const mockState = vi.hoisted(() => ({
|
||||
octokitConfigs: [],
|
||||
getGitHubAuth: vi.fn(),
|
||||
isGhCliActive: vi.fn(),
|
||||
isGhCliDisabled: vi.fn(),
|
||||
getGhCliToken: vi.fn(),
|
||||
getProviderApiBaseUrl: vi.fn(),
|
||||
getEffectiveProviderApiBaseUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@octokit/rest', () => ({
|
||||
Octokit: class {
|
||||
constructor(config) {
|
||||
mockState.octokitConfigs.push(config);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./auth.js', () => ({
|
||||
getGitHubAuth: mockState.getGitHubAuth,
|
||||
isGhCliActive: mockState.isGhCliActive,
|
||||
isGhCliDisabled: mockState.isGhCliDisabled,
|
||||
}));
|
||||
|
||||
vi.mock('./gh-cli-credential.js', () => ({
|
||||
getGhCliToken: mockState.getGhCliToken,
|
||||
}));
|
||||
|
||||
vi.mock('../git-providers/config.js', () => ({
|
||||
getProviderApiBaseUrl: mockState.getProviderApiBaseUrl,
|
||||
}));
|
||||
|
||||
vi.mock('../git-providers/project-config.js', () => ({
|
||||
getEffectiveProviderApiBaseUrl: mockState.getEffectiveProviderApiBaseUrl,
|
||||
}));
|
||||
|
||||
const { getOctokitOrNull } = await import('./octokit.js');
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.octokitConfigs.length = 0;
|
||||
mockState.getGitHubAuth.mockReset();
|
||||
mockState.isGhCliActive.mockReset().mockReturnValue(false);
|
||||
mockState.isGhCliDisabled.mockReset().mockReturnValue(false);
|
||||
mockState.getGhCliToken.mockReset().mockReturnValue(null);
|
||||
mockState.getProviderApiBaseUrl.mockReset();
|
||||
mockState.getEffectiveProviderApiBaseUrl.mockReset();
|
||||
});
|
||||
|
||||
describe('getOctokitOrNull base URL resolution', () => {
|
||||
test('uses the global base URL without a directory and never consults project overrides', () => {
|
||||
mockState.getGitHubAuth.mockReturnValue({ accessToken: 'ghp-test' });
|
||||
mockState.getProviderApiBaseUrl.mockReturnValue('https://api.github.com');
|
||||
|
||||
const octokit = getOctokitOrNull();
|
||||
|
||||
expect(octokit).not.toBeNull();
|
||||
expect(mockState.octokitConfigs).toHaveLength(1);
|
||||
expect(mockState.octokitConfigs[0].auth).toBe('ghp-test');
|
||||
expect(mockState.octokitConfigs[0].baseUrl).toBe('https://api.github.com');
|
||||
expect(mockState.getEffectiveProviderApiBaseUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolves the per-project override base URL for a directory', () => {
|
||||
mockState.getGitHubAuth.mockReturnValue({ accessToken: 'ghp-test' });
|
||||
mockState.getEffectiveProviderApiBaseUrl.mockReturnValue('https://github.enterprise.example');
|
||||
|
||||
const octokit = getOctokitOrNull('/work/override-project');
|
||||
|
||||
expect(octokit).not.toBeNull();
|
||||
expect(mockState.getEffectiveProviderApiBaseUrl).toHaveBeenCalledWith('github', '/work/override-project');
|
||||
expect(mockState.octokitConfigs[0].baseUrl).toBe('https://github.enterprise.example');
|
||||
});
|
||||
|
||||
test('returns null without a token', () => {
|
||||
mockState.getGitHubAuth.mockReturnValue(null);
|
||||
|
||||
expect(getOctokitOrNull('/work/override-project')).toBeNull();
|
||||
expect(mockState.octokitConfigs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises';
|
||||
import { getRemotes, getStatus } from '../git/index.js';
|
||||
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
||||
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js';
|
||||
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const directoryExists = async (dir) => {
|
||||
if (!dir) return false;
|
||||
@@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
||||
pushCandidate({
|
||||
owner: parent.owner.login,
|
||||
repo: parent.name,
|
||||
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
|
||||
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
|
||||
}, candidate.remoteName, candidate.priority + 0.1);
|
||||
}
|
||||
|
||||
@@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
||||
pushCandidate({
|
||||
owner: source.owner.login,
|
||||
repo: source.name,
|
||||
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
|
||||
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
|
||||
}, candidate.remoteName, candidate.priority + 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveGitHubRepoFromDirectory } from './index.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
|
||||
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const REPO_METADATA_TTL_MS = 5 * 60_000;
|
||||
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
|
||||
@@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
||||
result.push({
|
||||
owner: parent.owner.login,
|
||||
repo: parent.name,
|
||||
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
|
||||
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
@@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
||||
result.push({
|
||||
owner: source.owner.login,
|
||||
repo: source.name,
|
||||
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
|
||||
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
|
||||
source: 'upstream',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { getRemoteUrl } from '../../git/index.js';
|
||||
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
|
||||
|
||||
export const parseGitHubRemoteUrl = (raw) => {
|
||||
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||
|
||||
const webHostFromOrigin = (webOrigin) => {
|
||||
try {
|
||||
return new URL(webOrigin).hostname || 'github.com';
|
||||
} catch {
|
||||
return 'github.com';
|
||||
}
|
||||
};
|
||||
|
||||
export const parseGitHubRemoteUrl = (raw, { host = 'github.com', webOrigin = 'https://github.com' } = {}) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => {
|
||||
}
|
||||
|
||||
// git@github.com:OWNER/REPO.git
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
const rest = value.slice('git@github.com:'.length);
|
||||
const scpPrefix = `git@${host}:`;
|
||||
if (value.startsWith(scpPrefix)) {
|
||||
const rest = value.slice(scpPrefix.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// ssh://git@github.com/OWNER/REPO.git
|
||||
if (value.startsWith('ssh://git@github.com/')) {
|
||||
const rest = value.slice('ssh://git@github.com/'.length);
|
||||
const sshPrefix = `ssh://git@${host}/`;
|
||||
if (value.startsWith(sshPrefix)) {
|
||||
const rest = value.slice(sshPrefix.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// https://github.com/OWNER/REPO(.git)
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== 'github.com') {
|
||||
if (url.hostname !== host) {
|
||||
return null;
|
||||
}
|
||||
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or
|
||||
if (!remoteUrl) {
|
||||
return { repo: null, remoteUrl: null };
|
||||
}
|
||||
const webOrigin = getGitHubWebOrigin();
|
||||
const host = webHostFromOrigin(webOrigin);
|
||||
return {
|
||||
repo: parseGitHubRemoteUrl(remoteUrl),
|
||||
repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,874 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user