Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,14 +569,16 @@ write path, same fail-closed webhook rule using the mechanism GitLab actually
offers (token echo, constant-time compare). Walkthrough:
[`docs/gitlab.md`](docs/gitlab.md).

**Local mode** — a repository registered by path (`specd connect .`). specd
holds no credential for any host here and never will. Where `origin` points at
GitHub or GitLab and that host's CLI (`gh`, `glab`) is installed and signed in
on the same machine, setup and build branches are pushed and opened as real
PRs through *your* account; the CLI is checked before anything is pushed, so a
repository specd cannot open a review on is never published to.
`SPECD_LOCAL_OPEN_PR=0` keeps everything local — the branch is committed
either way
**Local mode** — a repository registered by path (`specd connect .`), read and
written on disk. Where `origin` is github.com or gitlab.com and that host's CLI
(`gh`, `glab`) is signed in on the same machine, setup and build branches are
pushed and opened as real PRs through *your* account. For a **self-managed**
instance — where specd refuses to guess what software a host runs — the local
step takes an optional **review credential**: pick the host, give the instance
URL and a token, and it opens the merge request with that. The token opens
reviews and nothing else; your own git does the push, and leaving it unset
keeps local mode credential-free. `SPECD_LOCAL_OPEN_PR=0` disables the whole
path — the branch is committed either way
([`knowledge/decisions/0020-local-mode-borrows-the-host-cli.md`](knowledge/decisions/0020-local-mode-borrows-the-host-cli.md)).

**Jira** — connect, import issues, backlink comments and status mirroring work
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RepositoriesService } from './projects/repositories.service.js';
import { ConnectionsService } from './projects/connections.service.js';

import { LocalGitAdapter } from './vcs/local-git.adapter.js';
import { LocalReviewService } from './vcs/local-review.service.js';
import { VcsService } from './vcs/vcs.service.js';
import { GitHubAppService } from './vcs/github-app.service.js';
import { GitHubWebhookService } from './vcs/github-webhook.service.js';
Expand Down Expand Up @@ -78,6 +79,7 @@ import { RunnersController } from './runners/runners.controller.js';
RepositoriesService,
ConnectionsService,
LocalGitAdapter,
LocalReviewService,
VcsService,
GitHubAppService,
GitHubWebhookService,
Expand Down
57 changes: 54 additions & 3 deletions apps/api/src/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import { KnowledgeService } from '../knowledge/knowledge.service.js';
import { ModelRouter } from '../agents/model.router.js';
import { Vault } from '../common/vault.js';
import { JiraAdapter } from '../tracker/jira.adapter.js';
import { VcsError, normalizeInstanceUrl } from '../vcs/vcs.types.js';
import { GitHubAdapter } from '../vcs/github.adapter.js';
import { GitLabAdapter } from '../vcs/gitlab.adapter.js';

class CreateProjectDto {
@IsString() @MinLength(1) @MaxLength(80) name!: string;
Expand Down Expand Up @@ -73,6 +76,11 @@ class ConnectVcsDto {
@IsIn(['local', 'github', 'gitlab']) provider!: string;
@IsOptional() @IsString() token?: string;
@IsOptional() @IsString() instanceUrl?: string;
/**
* Local mode only: the host to open pull/merge requests on, if any. Absent
* keeps local mode exactly as it was — a branch, and no credential held.
*/
@IsOptional() @IsIn(['github', 'gitlab']) reviewProvider?: 'github' | 'gitlab';
}

class ConnectTrackerDto {
Expand Down Expand Up @@ -295,15 +303,58 @@ export class ProjectsController {
) {
const project = await this.projects.bySlug(slug);
await this.projects.requireRole(user.sub, project.id, ['owner', 'maintainer']);

// Normalized before it is stored, so the complaint lands on the field
// somebody just typed into rather than on the repository list one call
// later — and so every later caller (webhooks, indexing, builds) reads a
// URL that is already in a shape `fetch` accepts.
let instanceUrl: string | null = null;
if (dto.instanceUrl?.trim()) {
try {
instanceUrl = normalizeInstanceUrl(dto.instanceUrl);
} catch (err) {
throw new BadRequestException(err instanceof VcsError ? err.message : String(err));
}
}

// Local mode may carry a credential used for one thing: opening the
// review. It is proved here, live, because the wizard must not claim a
// connection that fails later inside a run (§6 guardrail) — and because
// "the merge request never appeared" is a bad way to learn a token is
// wrong.
let connectedAs: string | undefined;
const reviewProvider = dto.provider === 'local' ? dto.reviewProvider ?? null : null;
if (reviewProvider) {
if (!dto.token) {
throw new BadRequestException(
`A ${reviewProvider === 'gitlab' ? 'GitLab' : 'GitHub'} token is needed to open reviews from local mode. Leave the review host unset to keep local mode credential-free.`,
);
}
try {
const identity =
reviewProvider === 'gitlab'
? await new GitLabAdapter(dto.token, instanceUrl ?? undefined).verify()
: await new GitHubAdapter(
dto.token,
instanceUrl ? `${instanceUrl}/api/v3` : undefined,
).verify();
connectedAs = identity.username;
} catch (err) {
throw new BadRequestException(
err instanceof VcsError ? err.message : err instanceof Error ? err.message : String(err),
);
}
}

await this.connections.upsert({
projectId: project.id,
kind: 'vcs',
provider: dto.provider,
label: dto.provider === 'local' ? 'local runner' : dto.instanceUrl ?? dto.provider,
settings: { instanceUrl: dto.instanceUrl ?? null },
label: dto.provider === 'local' ? 'local runner' : instanceUrl ?? dto.provider,
settings: { instanceUrl, reviewProvider },
secret: dto.token ?? null,
});
return { ok: true };
return connectedAs ? { ok: true, connectedAs } : { ok: true };
}

@Post(':slug/connections/tracker')
Expand Down
53 changes: 43 additions & 10 deletions apps/api/src/vcs/github.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { collectSamples } from './scan-targets.js';
import {
IGNORED_DIRS,
VcsError,
describeTransportFailure,
reviewHint,
type ChangeResult,
type OpenedReview,
Expand All @@ -27,6 +28,15 @@ import {
* (which mints installation tokens per run) is P1-scope wiring on top of this
* class, not a change to it.
*/
/** The origin of an API base, for an error message. Falls back to the raw value. */
function hostOf(apiBase: string): string {
try {
return new URL(apiBase).origin;
} catch {
return apiBase;
}
}

export class GitHubAdapter implements VcsAdapter {
readonly provider = 'github';

Expand All @@ -42,16 +52,27 @@ export class GitHubAdapter implements VcsAdapter {
}

private async api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.apiBase}${path}`, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${this.token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
let res: Response;
try {
res = await fetch(`${this.apiBase}${path}`, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${this.token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
} catch (err) {
// As in the GitLab adapter: a request that never reached the host
// rejects with a TypeError, which is not an HttpException and so
// reaches the caller as an opaque 500. Rare against api.github.com,
// routine against an Enterprise Server behind a VPN.
const explained = describeTransportFailure(err, hostOf(this.apiBase));
if (explained) throw new VcsError(explained, err);
throw err;
}

if (!res.ok) {
const body = await res.text();
Expand Down Expand Up @@ -286,6 +307,18 @@ export class GitHubAdapter implements VcsAdapter {
}
}

/**
* Prove a token, and say who it belongs to.
*
* Only meaningful for a user token — an App installation token has no user,
* and the App path proves itself by listing what it was granted instead.
* Used at connect time by local mode's review credential.
*/
async verify(): Promise<{ username: string; name: string }> {
const me = await this.api<{ login: string; name: string | null }>('/user');
return { username: me.login, name: me.name ?? me.login };
}

/** Repo picker source: exactly what the installation was granted (§6 step 2). */
async listInstallationRepositories(): Promise<
{ id: string; fullName: string; defaultBranch: string; language: string | null }[]
Expand Down
Loading