Skip to content
Draft
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
29 changes: 29 additions & 0 deletions docs/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ paths:
taihuPatClear: {type: boolean}
gitToken: {type: string, writeOnly: true}
gitTokenClear: {type: boolean}
gitLabToken: {type: string, writeOnly: true, description: "Personal access token for the configured GitLab instance. Blank preserves the stored token."}
gitLabTokenClear: {type: boolean}
gitLabBaseUrl: {type: string, description: "HTTP(S) GitLab instance URL. Changing it requires replacing or clearing the stored GitLab token."}
codeBuddyApiKey: {type: string, writeOnly: true}
codeBuddyApiKeyClear: {type: boolean}
codeBuddyInternetEnvironment: {type: string}
Expand All @@ -712,6 +715,30 @@ paths:
$ref: "#/components/schemas/User"
"403":
$ref: "#/components/responses/Unauthorized"
/api/users/{id}/gitlab-token/verify:
get:
tags: [Users]
operationId: verifyOwnGitLabToken
summary: Verify the current user's saved GitLab identity
description: Checks the configured GitLab instance's /api/v4/user endpoint. Repository permissions are not checked.
parameters:
- name: id
in: path
required: true
schema: {type: integer, format: int64}
responses:
"200":
description: Identity verification result; token values are never returned
content:
application/json:
schema:
type: object
properties:
valid: {type: boolean}
login: {type: string}
error: {type: string}
"403":
$ref: "#/components/responses/Unauthorized"
/api/business-domains:
get:
tags: [BusinessDomains]
Expand Down Expand Up @@ -3462,6 +3489,8 @@ components:
avatarUrl: {type: string}
taihuPatConfigured: {type: boolean, readOnly: true}
gitTokenConfigured: {type: boolean, readOnly: true}
gitLabTokenConfigured: {type: boolean, readOnly: true}
gitLabBaseUrl: {type: string}
codeBuddyApiKeyConfigured: {type: boolean, readOnly: true}
codexApiKeyConfigured: {type: boolean, readOnly: true}
codexBaseUrl: {type: string}
Expand Down
14 changes: 14 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ docker compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u loopfor

This repeatable SQL changes only instruction fields that still match the original public seed. It preserves operator edits and project copies. New installations already receive these instructions from the public catalog seed; application startup does not migrate data.

## Self-managed GitLab

In personal settings, configure a GitLab instance URL and a personal access token separately from the GitHub token. Use HTTPS when available; HTTP sends the token without transport encryption. The identity check calls `/api/v4/user` and does not assert repository permissions.

The project owner must configure the instance before binding its repositories. Repository links must match that instance, including the scheme, host and port. Nested group paths and scp-style SSH links are accepted; SSH references are normalized to the configured HTTP(S) clone endpoint for token authentication. Set the repository's actual default branch when it differs from `main`.

Git operations use the responsible user's GitLab credential and reject a different instance. MR lookup, delivery-state refresh and scheduled merge operations use the project owner's GitLab token, following the existing GitHub adapter's ownership model. Normal SOPs do not automatically create an MR solely because a repository was bound. GitLab Issue import and promotion/default-branch reconciliation are not included in this initial adapter.

Use a personal token with the API and repository permissions needed by the intended operations. Access is still limited by project membership and protected branches. Tokens are stored in `user_configs`, under the same storage model as existing provider credentials, and are omitted from public user responses.

This adds nullable `gitlab_token` and `gitlab_base_url` columns through the existing incremental GORM schema setup. Upgrade execution-machine runners to **0.8.0 or newer** before dispatching GitLab tasks: older runners do not understand the host-bound credential field and are rejected. Existing GitHub and legacy-hosting tasks retain their previous protocol.

For an opt-in read-only Git probe, set `LOOPFORGE_TEST_GITLAB_BASE`, `LOOPFORGE_TEST_GITLAB_REMOTE` and `LOOPFORGE_TEST_GITLAB_TOKEN` in the test process environment, then run `go test ./internal/gitwork -run TestGitLabLiveReadOnly`. The probe only lists remote branches. Do not commit credentials or enable live tests against a production repository by default.

## GitHub credentials

GitHub Issue import and PR status synchronization use the project owner's Git token. Git operations and the optional GitHub MCP integration use the responsible user's token. Public reads may work without a token; private repositories and writes require repository access. GitHub MCP must be installed explicitly and sends credentials only to its official HTTPS endpoint.
Expand Down
75 changes: 75 additions & 0 deletions e2e/public/gitlab.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {test,expect} from '@playwright/test';
import {signInUnconfigured} from './helpers.mjs';

test.use({trace:'off',screenshot:'off'});

test('GitLab credentials are independent, redacted and verified as identity only',async({page})=>{
await signInUnconfigured(page);
await expect(page.locator('#aiAssistant .ai-compose-input')).toBeEnabled();
const current=await page.request.get('/api/auth/current').then(r=>r.json());
const user=current.user;
const credentials=`/api/users/${user.id}/credentials`;
const inert='e2e-inert-gitlab-token-not-for-network-use';
try{
await page.goto('/#/settings');
await page.locator('[name="gitLabBaseUrl"]').fill('https://gitlab.example.com');
await page.locator('[name="gitLabToken"]').fill(inert);
const pending=page.waitForResponse(r=>r.request().method()==='PUT'&&new URL(r.url()).pathname===credentials);
await page.locator('#userCredentialForm button[type="submit"]').click();
const saved=await pending;
expect(saved.ok()).toBe(true);
const publicUser=await saved.json();
expect(publicUser.gitLabTokenConfigured).toBe(true);
expect(publicUser.gitLabToken).toBeUndefined();
expect(!!publicUser.gitTokenConfigured).toBe(!!user.gitTokenConfigured);
expect(JSON.stringify(publicUser)).not.toContain(inert);
await page.reload();
await expect(page.locator('[name="gitLabToken"]')).toHaveValue('');
await expect(page.locator('[name="gitLabBaseUrl"]')).toHaveValue('https://gitlab.example.com');
await page.route('**/api/users/*/gitlab-token/verify',route=>route.fulfill({json:{valid:true,login:'gitlab-test-user'}}));
await page.locator('#verifyGitLabToken').click();
await expect(page.locator('#gitLabTokenVerifyStatus')).toContainText('gitlab-test-user');
await expect(page.locator('#gitLabTokenVerifyStatus')).toContainText('仓库权限尚未验证');
await page.locator('#langToggle').click();
await expect(page.locator('#userCredentialForm')).toContainText('Self-managed GitLab');
}finally{
expect((await page.request.put(credentials,{data:{gitLabTokenClear:true,gitLabBaseUrl:''}})).ok()).toBe(true);
}
});


test('project repository input accepts and saves a configured GitLab URL',async({page})=>{
await signInUnconfigured(page);
await expect(page.locator('#aiAssistant .ai-compose-input')).toBeEnabled();
const {user}=await page.request.get('/api/auth/current').then(r=>r.json());
expect(!!user.gitLabTokenConfigured).toBe(false);
const credentials=`/api/users/${user.id}/credentials`;
const projectId='e2e-gitlab-repository';
let repositoryId;
try{
expect((await page.request.put(credentials,{data:{gitLabBaseUrl:'https://gitlab.example.com',gitLabToken:'e2e-inert-gitlab-token'}})).ok()).toBe(true);
const existing=await page.request.get(`/api/projects/${projectId}`);
if(existing.status()===404)expect((await page.request.post('/api/projects',{data:{id:projectId,key:'E2GL',name:'E2E GitLab repository',ownerId:user.id}})).ok()).toBe(true);
await page.reload();
await page.locator('#projectSwitcher').selectOption(projectId);
await page.goto('/#/projects');
await page.locator('#addRepoDraft').click();
const input=page.locator('[data-r="repoUrl"]');
await expect(input).toHaveAttribute('placeholder',/GitHub.*GitLab/);
await input.fill('https://gitlab.example.com/team/sub/repository.git');
await input.press('Tab');
await expect(page.locator('[data-r="name"]')).toHaveValue('repository');
const pending=page.waitForResponse(r=>r.request().method()==='POST'&&new URL(r.url()).pathname==='/api/repositories');
await page.locator('[data-save-repo]').click();
const response=await pending;expect(response.ok()).toBe(true);
const saved=await response.json();repositoryId=saved.id;
expect(saved.provider).toBe('gitlab');
expect(saved.cloneUrl).toBe('https://gitlab.example.com/team/sub/repository.git');
expect(saved.gongfengProjectId).toBe('gitlab:https://gitlab.example.com/team/sub/repository');
await page.reload();
await expect(page.locator(`a.repo-cell-link[href="${saved.webUrl}"]`).first()).toBeVisible();
}finally{
if(repositoryId)expect((await page.request.delete(`/api/repositories?id=${encodeURIComponent(repositoryId)}`)).ok()).toBe(true);
expect((await page.request.put(credentials,{data:{gitLabTokenClear:true,gitLabBaseUrl:''}})).ok()).toBe(true);
}
});
9 changes: 9 additions & 0 deletions internal/domain/delivery_mr.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package domain

import (
"fmt"
"net/url"
"sort"
"strings"
)
Expand All @@ -28,6 +29,14 @@ func GongfengMRURL(project string, iid int) string {
if project == "" || iid <= 0 {
return ""
}
if strings.HasPrefix(project, "gitlab:") {
raw := strings.TrimPrefix(project, "gitlab:")
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return ""
}
return fmt.Sprintf("%s/-/merge_requests/%d", strings.TrimRight(raw, "/"), iid)
}
if strings.HasPrefix(project, "github:") {
return fmt.Sprintf("https://github.com/%s/pull/%d", strings.TrimPrefix(project, "github:"), iid)
}
Expand Down
14 changes: 14 additions & 0 deletions internal/domain/gitlab_mr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package domain

import "testing"

func TestGitLabMRIdentity(t *testing.T) {
u := "http://gitlab.example.com:8443/team/sub/repo/-/merge_requests/42"
project, iid, got, ok := ParseGongfengMR(u)
if !ok || project != "gitlab:http://gitlab.example.com:8443/team/sub/repo" || iid != 42 || got != u {
t.Fatalf("bad GitLab MR parse: %q %d %q %v", project, iid, got, ok)
}
if canonical := GongfengMRURL(project, iid); canonical != u {
t.Fatalf("bad URL: %s", canonical)
}
}
44 changes: 44 additions & 0 deletions internal/domain/gitlab_repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package domain

import (
"net/url"
"strings"
)

// ParseGitLabRepositoryURL accepts only the configured GitLab instance.
func ParseGitLabRepositoryURL(raw, instance string) (GongfengRepoInfo, bool) {
instanceURL, err := url.Parse(strings.TrimSpace(instance))
if err != nil || (instanceURL.Scheme != "http" && instanceURL.Scheme != "https") || instanceURL.Host == "" || instanceURL.User != nil || instanceURL.RawQuery != "" || instanceURL.Fragment != "" {
return GongfengRepoInfo{}, false
}
basePath := strings.TrimRight(instanceURL.EscapedPath(), "/")
s := strings.Trim(strings.TrimSpace(raw), "\\\"'")
s = strings.TrimSpace(strings.TrimPrefix(s, "git clone "))
if strings.HasPrefix(s, "gitlab:") {
s = strings.TrimPrefix(s, "gitlab:")
}
if strings.HasPrefix(s, "git@") {
host, path, ok := strings.Cut(strings.TrimPrefix(s, "git@"), ":")
if !ok || !strings.EqualFold(host, instanceURL.Hostname()) {
return GongfengRepoInfo{}, false
}
s = instanceURL.Scheme + "://" + instanceURL.Host + "/" + strings.TrimLeft(path, "/")
}
u, err := url.Parse(s)
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !strings.EqualFold(u.Scheme, instanceURL.Scheme) || !strings.EqualFold(u.Host, instanceURL.Host) {
return GongfengRepoInfo{}, false
}
path := u.EscapedPath()
if basePath != "" {
if !strings.HasPrefix(path, basePath+"/") {
return GongfengRepoInfo{}, false
}
path = strings.TrimPrefix(path, basePath)
}
path = normalizeGongfengProjectPath(strings.TrimLeft(path, "/"))
if path == "" || strings.Contains(path, "%") {
return GongfengRepoInfo{}, false
}
base := instanceURL.Scheme + "://" + instanceURL.Host + basePath + "/" + path
return GongfengRepoInfo{Project: "gitlab:" + base, Name: path[strings.LastIndex(path, "/")+1:], WebURL: base, CloneURL: base + ".git"}, true
}
18 changes: 18 additions & 0 deletions internal/domain/gitlab_repository_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package domain

import "testing"

func TestGitLabRepositoryIsBoundToInstance(t *testing.T) {
base := "http://gitlab.example.com:8443"
for _, raw := range []string{"http://gitlab.example.com:8443/team/sub/repo.git", "git@gitlab.example.com:team/sub/repo.git"} {
info, ok := ParseGitLabRepositoryURL(raw, base)
if !ok || info.Project != "gitlab:http://gitlab.example.com:8443/team/sub/repo" {
t.Fatalf("unexpected parse %q: %+v %v", raw, info, ok)
}
}
for _, raw := range []string{"http://gitlab.example.com.attacker.invalid:9530/team/repo", "https://gitlab.example.com:8443/team/repo", "http://user@gitlab.example.com:8443/team/repo"} {
if _, ok := ParseGitLabRepositoryURL(raw, base); ok {
t.Fatalf("accepted %q", raw)
}
}
}
22 changes: 19 additions & 3 deletions internal/domain/mr.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
// either scheme and store the canonical https URL.
var githubPRURL = regexp.MustCompile(`https?://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/pull/(\d+)`)

var gitLabMRURL = regexp.MustCompile(`https?://[A-Za-z0-9.\-]+(?::[0-9]+)?/(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+/-/merge_requests/[0-9]+`)

var gongfengMRURL = regexp.MustCompile(`https?://git\.example\.com/([^\s"'<>#]+)/merge_requests/(\d+)`)

// GongfengMRRef is one merge request URL extracted from free text.
Expand All @@ -36,9 +38,6 @@ func ParseGongfengMRs(text string) []GongfengMRRef {
text = strings.ReplaceAll(text, match[0], "https://git.example.com/github:"+match[1]+"/merge_requests/"+match[2])
}
matches := gongfengMRURL.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
out := make([]GongfengMRRef, 0, len(matches))
seen := map[string]struct{}{}
for _, match := range matches {
Expand All @@ -54,6 +53,23 @@ func ParseGongfengMRs(text string) []GongfengMRRef {
seen[key] = struct{}{}
out = append(out, GongfengMRRef{Project: project, IID: iid, URL: GongfengMRURL(project, iid)})
}
for _, value := range gitLabMRURL.FindAllString(text, -1) {
repo, number, ok := strings.Cut(value, "/-/merge_requests/")
if !ok || strings.HasPrefix(repo, "https://git.example.com/") || strings.HasPrefix(repo, "http://git.example.com/") {
continue
}
iid, err := strconv.Atoi(number)
if err != nil || iid <= 0 {
continue
}
project := "gitlab:" + repo
key := strings.ToLower(project) + "#" + number
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, GongfengMRRef{Project: project, IID: iid, URL: value})
}
return out
}

Expand Down
20 changes: 14 additions & 6 deletions internal/domain/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const RunnerVersionWorkspacePreparation = "0.6.0"
// a missing branch when the caller explicitly continues an existing branch.
const RunnerVersionExistingWorkBranch = "0.7.0"

// RunnerVersionGitLabCredentials is the first runner with host-bound GitLab authentication.
const RunnerVersionGitLabCredentials = "0.8.0"

type Runner struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -114,6 +117,10 @@ func PublicRunners(items []Runner) []Runner {

// SupportsExistingWorkBranch guards strict checkout and synchronization;
// older daemons silently create a new branch when the remote head is missing.
func (r Runner) SupportsGitLabCredentials() bool {
return versionAtLeast(r.RunnerVersion, RunnerVersionGitLabCredentials)
}

func (r Runner) SupportsExistingWorkBranch() bool {
return versionAtLeast(r.RunnerVersion, RunnerVersionExistingWorkBranch)
}
Expand Down Expand Up @@ -415,12 +422,13 @@ type RunnerInvocation struct {
// GitToken is only set for private remotes and is cleared from the stored
// payload once the task completes.
type RunnerRepository struct {
RepositoryID string `json:"repositoryId,omitempty"`
Name string `json:"name,omitempty"`
CloneURL string `json:"cloneUrl,omitempty"`
Ref string `json:"ref,omitempty"`
WorkBranch string `json:"workBranch,omitempty"`
GitToken string `json:"gitToken,omitempty"`
RepositoryID string `json:"repositoryId,omitempty"`
Name string `json:"name,omitempty"`
CloneURL string `json:"cloneUrl,omitempty"`
Ref string `json:"ref,omitempty"`
WorkBranch string `json:"workBranch,omitempty"`
GitToken string `json:"gitToken,omitempty"`
GitAuthBaseURL string `json:"gitAuthBaseUrl,omitempty"`
// RequireExistingWorkBranch keeps explicit continuation on an existing
// remote head. Missing/deleted heads are errors, never branch creation.
RequireExistingWorkBranch bool `json:"requireExistingWorkBranch,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions internal/domain/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type User struct {
PlatformRole string `json:"platformRole,omitempty"`
GitToken string `json:"gitToken,omitempty"`
GitTokenConfigured bool `json:"gitTokenConfigured,omitempty"`
GitLabToken string `json:"gitLabToken,omitempty"`
GitLabTokenConfigured bool `json:"gitLabTokenConfigured,omitempty"`
GitLabBaseURL string `json:"gitLabBaseUrl,omitempty"`
CodeBuddyAPIKey string `json:"codeBuddyApiKey,omitempty"`
CodeBuddyAPIKeyConfigured bool `json:"codeBuddyApiKeyConfigured,omitempty"`
CodeBuddyInternetEnvironment string `json:"codeBuddyInternetEnvironment,omitempty"`
Expand All @@ -50,6 +53,8 @@ func PublicUser(u User) User {
u.ClaudeCodeAPIKey = ""
u.GitTokenConfigured = strings.TrimSpace(u.GitToken) != ""
u.GitToken = ""
u.GitLabTokenConfigured = strings.TrimSpace(u.GitLabToken) != ""
u.GitLabToken = ""
u.CodeBuddyAPIKeyConfigured = strings.TrimSpace(u.CodeBuddyAPIKey) != ""
u.CodeBuddyAPIKey = ""
return u
Expand Down
9 changes: 9 additions & 0 deletions internal/engine/align.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package engine
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"time"

"agentflow/internal/domain"
"agentflow/internal/gitwork"
)

// BaselineAlignWorkflowID is the built-in SOP that resolves default→integrate
Expand Down Expand Up @@ -129,6 +131,13 @@ func AlignIntegrateBaseline(repository domain.RepositoryContext, gitToken string
work.IntegrateBranch = domain.NormalizeIntegrateBranch(repository.IntegrateBranch)
work.BaseBranch = work.IntegrateBranch
ctx := withGitToken(context.Background(), gitToken)
if repository.Provider == "gitlab" {
u, err := url.Parse(repository.CloneURL)
if err != nil || u.Host == "" {
return fmt.Errorf("GitLab 仓库地址无效")
}
ctx = gitwork.WithHostCredential(ctx, u.Scheme+"://"+u.Host, gitToken)
}
if err := prepareRepository(ctx, dir, work); err != nil {
return err
}
Expand Down
Loading
Loading