-
Notifications
You must be signed in to change notification settings - Fork 520
feat: install plugins from GitLab repositories #1801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Install plugins from GitLab.com and self-managed GitLab repository URLs with `/plugins install <url>`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| /** | ||
| * `plugin` domain (L3) — resolves GitLab plugin sources through instance APIs. | ||
| * | ||
| * Selects the latest release or requested ref and produces repository archive | ||
| * URLs for GitLab.com and self-managed GitLab instances. | ||
| */ | ||
|
|
||
| import type { GitlabRef } from './source'; | ||
|
|
||
| export interface GitlabSourceInput { | ||
| readonly kind: 'gitlab'; | ||
| readonly baseUrl: string; | ||
| readonly projectPath: string; | ||
| readonly ref?: GitlabRef; | ||
| } | ||
|
|
||
| export interface GitlabSourceResolution { | ||
| readonly tarballUrl: string; | ||
| } | ||
|
|
||
| export async function resolveGitlabSource( | ||
| input: GitlabSourceInput, | ||
| ): Promise<GitlabSourceResolution> { | ||
| if (input.ref !== undefined) { | ||
| return { tarballUrl: archiveUrl(input, input.ref) }; | ||
| } | ||
|
|
||
| const latestTag = await tryResolveLatestReleaseTag(input); | ||
| return { | ||
| tarballUrl: | ||
| latestTag === undefined | ||
| ? archiveUrl(input) | ||
| : archiveUrl(input, { kind: 'tag', value: latestTag }), | ||
| }; | ||
| } | ||
|
|
||
| async function tryResolveLatestReleaseTag( | ||
| input: GitlabSourceInput, | ||
| ): Promise<string | undefined> { | ||
| const projectId = encodeURIComponent(input.projectPath); | ||
| const url = `${input.baseUrl}/api/v4/projects/${projectId}/releases/permalink/latest`; | ||
| const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) }); | ||
| if (resp.status === 404) return undefined; | ||
| if (!resp.ok) { | ||
| throw new Error( | ||
| `Could not look up latest release of \`${input.projectPath}\` on ${input.baseUrl}: ` + | ||
| `HTTP ${resp.status} ${resp.statusText}.`, | ||
| ); | ||
| } | ||
|
|
||
| const release = (await resp.json()) as { tag_name?: unknown }; | ||
| if (typeof release.tag_name !== 'string' || release.tag_name.length === 0) { | ||
| throw new Error( | ||
| `Could not determine the latest release tag of \`${input.projectPath}\` on ${input.baseUrl}.`, | ||
| ); | ||
| } | ||
| return release.tag_name; | ||
| } | ||
|
|
||
| function archiveUrl(input: GitlabSourceInput, ref?: GitlabRef): string { | ||
| const projectId = encodeURIComponent(input.projectPath); | ||
| const url = new URL( | ||
| `${input.baseUrl}/api/v4/projects/${projectId}/repository/archive.zip`, | ||
| ); | ||
| if (ref !== undefined) { | ||
| url.searchParams.set('sha', ref.value); | ||
| if (ref.kind === 'tag') url.searchParams.set('ref_type', 'tags'); | ||
| } | ||
| return url.toString(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Scenario: GitLab plugin source resolution through instance API endpoints. | ||
| * Responsibilities: build archive URLs for explicit refs, select the latest | ||
| * release, and fall back to the default branch when no release exists. | ||
| * Wiring: real resolver with only the external `fetch` boundary stubbed. | ||
| * Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/app/plugin/gitlab-resolver.test.ts | ||
| */ | ||
|
|
||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The applicable Useful? React with 👍 / 👎. |
||
|
|
||
| import { resolveGitlabSource } from '#/app/plugin/gitlab-resolver'; | ||
|
|
||
| describe('resolveGitlabSource', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it('builds an archive URL directly for an explicit ref', async () => { | ||
| const fetch = vi.fn(); | ||
| vi.stubGlobal('fetch', fetch); | ||
|
|
||
| await expect( | ||
| resolveGitlabSource({ | ||
| kind: 'gitlab', | ||
| baseUrl: 'https://gitlab.example.com', | ||
| projectPath: 'team/plugins/sample', | ||
| ref: { kind: 'tag', value: 'release#1' }, | ||
| }), | ||
| ).resolves.toEqual({ | ||
| tarballUrl: | ||
| 'https://gitlab.example.com/api/v4/projects/team%2Fplugins%2Fsample/repository/archive.zip?sha=release%231&ref_type=tags', | ||
| }); | ||
| expect(fetch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('uses the latest release for a bare repository URL', async () => { | ||
| const fetch = vi | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This test verifies two distinct behaviors—selecting a latest release and falling back after a 404—using ordered mock responses in one Useful? React with 👍 / 👎. |
||
| .fn() | ||
| .mockResolvedValue(new Response(JSON.stringify({ tag_name: 'v1.2.3' }), { status: 200 })); | ||
| vi.stubGlobal('fetch', fetch); | ||
|
|
||
| await expect( | ||
| resolveGitlabSource({ | ||
| kind: 'gitlab', | ||
| baseUrl: 'https://gitlab.example.com', | ||
| projectPath: 'team/sample', | ||
| }), | ||
| ).resolves.toEqual({ | ||
| tarballUrl: | ||
| 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags', | ||
| }); | ||
| }); | ||
|
|
||
| it('falls back to the default branch when the project has no release', async () => { | ||
| vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); | ||
|
|
||
| await expect( | ||
| resolveGitlabSource({ | ||
| kind: 'gitlab', | ||
| baseUrl: 'https://gitlab.example.com', | ||
| projectPath: 'team/sample', | ||
| }), | ||
| ).resolves.toEqual({ | ||
| tarballUrl: | ||
| 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip', | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a self-managed instance is served below a relative URL, such as
https://code.example.com/gitlab, its documented HTTPS clone URLhttps://code.example.com/gitlab/team/sample.gitis parsed withbaseUrlreduced to the origin andgitlabfolded intoprojectPath. The resolver consequently requests/api/v4/projects/gitlab%2Fteam%2Fsampleinstead of the instance endpoint/gitlab/api/v4/projects/team%2Fsample, so these supported self-managed installs fail. Preserve or otherwise resolve the instance path prefix; the legacy parser has the same issue.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for catching this. Supporting arbitrary relative URL roots requires distinguishing the instance path prefix from the project namespace, which cannot be determined unambiguously from the clone URL alone. To keep this PR small and its support scope clear, cdfbfab narrows the English and Chinese docs to self-managed GitLab instances served from the origin root and explicitly notes that relative URL roots are not currently supported. Full relative-root discovery/support can be handled separately.