From 73e6c659a0c64adecd71a0d2cd7d06f9740f2b28 Mon Sep 17 00:00:00 2001 From: haoqiancheng Date: Tue, 27 Oct 2026 18:00:14 +0800 Subject: [PATCH 1/2] feat: add self-managed GitLab repository integration --- docs/api/openapi.yaml | 29 ++++ docs/deployment.md | 14 ++ e2e/public/gitlab.spec.mjs | 38 +++++ internal/domain/delivery_mr.go | 9 ++ internal/domain/gitlab_mr_test.go | 14 ++ internal/domain/gitlab_repository.go | 44 ++++++ internal/domain/gitlab_repository_test.go | 18 +++ internal/domain/mr.go | 22 ++- internal/domain/runner.go | 20 ++- internal/domain/user.go | 5 + internal/engine/align.go | 9 ++ internal/engine/clarify_workspace.go | 32 +++- internal/engine/git_auth.go | 18 +++ internal/engine/gitlab_auth_test.go | 51 +++++++ internal/engine/integrate.go | 6 +- internal/engine/node_execute.go | 1 + internal/engine/repository_workspace.go | 8 +- internal/engine/run_environment.go | 9 ++ internal/engine/runner_align.go | 11 ++ internal/engine/runner_dispatch.go | 39 ++++- internal/engine/runner_integrate.go | 5 + internal/engine/work_branch_sync.go | 9 +- internal/engine/workspace_preparation.go | 1 + internal/gitwork/auth.go | 27 ++++ internal/gitwork/git.go | 11 +- internal/gitwork/gitlab_live_test.go | 24 +++ internal/gitwork/host_auth_test.go | 37 +++++ internal/httpapi/catalog.go | 30 +++- internal/httpapi/git_token_verify.go | 51 +++++++ internal/httpapi/gitlab_repository_test.go | 33 +++++ internal/httpapi/schedule.go | 2 +- internal/httpapi/server.go | 1 + internal/httpapi/stream.go | 2 +- internal/httpapi/users.go | 2 +- internal/repo/models.go | 2 + internal/repo/provider_credentials_test.go | 46 ++++++ internal/repo/users.go | 29 +++- internal/runner/align.go | 2 +- internal/runner/daemon.go | 62 ++++++-- internal/runner/integrate.go | 2 +- internal/runner/work_branch.go | 2 +- internal/runner/workspace_preparation.go | 14 ++ internal/runtime/command_binding.go | 31 ++++ internal/runtime/gitlab_auth_test.go | 46 ++++++ internal/runtime/runtime.go | 18 ++- internal/runtime/workspace_preparation.go | 2 +- internal/tracker/gitlab.go | 162 +++++++++++++++++++++ internal/tracker/gitlab_test.go | 67 +++++++++ internal/tracker/merge_mr.go | 6 + internal/tracker/mr_delivery_sync.go | 12 +- web/app/issues.js | 18 ++- web/app/projects.js | 6 +- web/app/workbench.js | 11 +- 53 files changed, 1101 insertions(+), 69 deletions(-) create mode 100644 e2e/public/gitlab.spec.mjs create mode 100644 internal/domain/gitlab_mr_test.go create mode 100644 internal/domain/gitlab_repository.go create mode 100644 internal/domain/gitlab_repository_test.go create mode 100644 internal/engine/gitlab_auth_test.go create mode 100644 internal/gitwork/gitlab_live_test.go create mode 100644 internal/gitwork/host_auth_test.go create mode 100644 internal/httpapi/gitlab_repository_test.go create mode 100644 internal/runtime/gitlab_auth_test.go create mode 100644 internal/tracker/gitlab.go create mode 100644 internal/tracker/gitlab_test.go diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 855323c..d8a7204 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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} @@ -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] @@ -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} diff --git a/docs/deployment.md b/docs/deployment.md index 5c88d70..b3cf5e4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/e2e/public/gitlab.spec.mjs b/e2e/public/gitlab.spec.mjs new file mode 100644 index 0000000..e52f7c8 --- /dev/null +++ b/e2e/public/gitlab.spec.mjs @@ -0,0 +1,38 @@ +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); + } +}); diff --git a/internal/domain/delivery_mr.go b/internal/domain/delivery_mr.go index 4e283cf..cf843bc 100644 --- a/internal/domain/delivery_mr.go +++ b/internal/domain/delivery_mr.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "net/url" "sort" "strings" ) @@ -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) } diff --git a/internal/domain/gitlab_mr_test.go b/internal/domain/gitlab_mr_test.go new file mode 100644 index 0000000..a4adb66 --- /dev/null +++ b/internal/domain/gitlab_mr_test.go @@ -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) + } +} diff --git a/internal/domain/gitlab_repository.go b/internal/domain/gitlab_repository.go new file mode 100644 index 0000000..3a6967a --- /dev/null +++ b/internal/domain/gitlab_repository.go @@ -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 +} diff --git a/internal/domain/gitlab_repository_test.go b/internal/domain/gitlab_repository_test.go new file mode 100644 index 0000000..42af921 --- /dev/null +++ b/internal/domain/gitlab_repository_test.go @@ -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) + } + } +} diff --git a/internal/domain/mr.go b/internal/domain/mr.go index d8ddcbc..0d3168a 100644 --- a/internal/domain/mr.go +++ b/internal/domain/mr.go @@ -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. @@ -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 { @@ -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 } diff --git a/internal/domain/runner.go b/internal/domain/runner.go index 7ccf22d..cbb500e 100644 --- a/internal/domain/runner.go +++ b/internal/domain/runner.go @@ -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"` @@ -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) } @@ -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"` diff --git a/internal/domain/user.go b/internal/domain/user.go index b7221fd..6ce426f 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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"` @@ -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 diff --git a/internal/engine/align.go b/internal/engine/align.go index 592ad39..d61bba8 100644 --- a/internal/engine/align.go +++ b/internal/engine/align.go @@ -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 @@ -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 } diff --git a/internal/engine/clarify_workspace.go b/internal/engine/clarify_workspace.go index 9d6807e..63a009e 100644 --- a/internal/engine/clarify_workspace.go +++ b/internal/engine/clarify_workspace.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -52,6 +53,13 @@ func PrepareClarifyWorkspaceAtBranch(ctx context.Context, cacheDir string, repo } } ctx = withGitToken(ctx, token) + if repo.Provider == "gitlab" { + parsed, err := url.Parse(cloneURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("GitLab 仓库地址无效") + } + ctx = gitwork.WithHostCredential(ctx, parsed.Scheme+"://"+parsed.Host, token) + } if _, err := os.Stat(filepath.Join(cacheDir, ".git")); err == nil { // Existing checkout: refresh to the latest base branch. @@ -68,10 +76,14 @@ func PrepareClarifyWorkspaceAtBranch(ctx context.Context, cacheDir string, repo // Clear any stale non-git leftovers before cloning. _ = os.RemoveAll(cacheDir) + remote := cloneURL + if repo.Provider != "gitlab" { + remote = gitwork.RewriteRemoteForToken(cloneURL, token) + } args := []string{ "clone", "--no-hardlinks", "--single-branch", "--depth", "1", "--branch", baseBranch, - gitwork.RewriteRemoteForToken(cloneURL, token), cacheDir, + remote, cacheDir, } if _, err := runGit(ctx, cacheDir, args...); err != nil { _ = os.RemoveAll(cacheDir) @@ -118,3 +130,21 @@ func refreshClarifyWorkspace(ctx context.Context, cacheDir, cloneURL, baseBranch func ResolveClarifyGitToken(s *repo.Store, responsibleUserID int64) string { return resolveGitToken(s, responsibleUserID) } + +// ResolveClarifyGitTokenForRepository selects credentials only for their configured host. +func ResolveClarifyGitTokenForRepository(s *repo.Store, userID int64, repository domain.Repository) string { + if repository.Provider != "gitlab" { + return resolveGitToken(s, userID) + } + if s == nil { + return "" + } + user, ok := s.User(userID) + if !ok { + return "" + } + if _, ok := domain.ParseGitLabRepositoryURL(repository.CloneURL, user.GitLabBaseURL); !ok { + return "" + } + return strings.TrimSpace(user.GitLabToken) +} diff --git a/internal/engine/git_auth.go b/internal/engine/git_auth.go index 9d9891f..9fb3e8b 100644 --- a/internal/engine/git_auth.go +++ b/internal/engine/git_auth.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "agentflow/internal/domain" "agentflow/internal/gitwork" "agentflow/internal/repo" ) @@ -118,3 +119,20 @@ func applyWorkspaceGitAuth(ctx context.Context, workspace string) error { } return nil } + +type gitLabCredentialsKey struct{} +type gitLabCredentials struct{ base, token string } + +func withGitLabCredentials(ctx context.Context, base, token string) context.Context { + return context.WithValue(ctx, gitLabCredentialsKey{}, gitLabCredentials{base, token}) +} +func gitContextForRepository(ctx context.Context, rc domain.RepositoryContext) context.Context { + if rc.Provider != "gitlab" { + return ctx + } + cred, _ := ctx.Value(gitLabCredentialsKey{}).(gitLabCredentials) + if _, ok := domain.ParseGitLabRepositoryURL(rc.CloneURL, cred.base); !ok { + return gitwork.WithHostCredential(ctx, "", "") + } + return gitwork.WithHostCredential(ctx, cred.base, cred.token) +} diff --git a/internal/engine/gitlab_auth_test.go b/internal/engine/gitlab_auth_test.go new file mode 100644 index 0000000..394855b --- /dev/null +++ b/internal/engine/gitlab_auth_test.go @@ -0,0 +1,51 @@ +package engine + +import ( + "agentflow/internal/domain" + "agentflow/internal/gitwork" + "agentflow/internal/repo" + "context" + "testing" +) + +func TestGitLabCredentialBoundPerRepository(t *testing.T) { + base := "http://git.example.test:9530" + gitlab := domain.RepositoryContext{RepositoryID: "gl", Provider: "gitlab", CloneURL: base + "/team/repo.git"} + github := domain.RepositoryContext{RepositoryID: "gh", Provider: "github", CloneURL: "https://github.com/team/repo.git"} + run := domain.Run{Repositories: []domain.RepositoryContext{gitlab, github}} + ctx := withGitLabCredentials(withGitToken(context.Background(), "github-secret"), base, "gitlab-secret") + auth, ok := gitwork.HostCredentialFrom(gitContextForRepository(ctx, gitlab)) + if !ok || auth.Token != "gitlab-secret" { + t.Fatal("GitLab credential missing") + } + if _, ok := gitwork.HostCredentialFrom(gitContextForRepository(ctx, github)); ok { + t.Fatal("GitHub got GitLab host credential") + } + inv := &domain.RunnerInvocation{} + bindRunnerRepositories(inv, run, "github-secret", "", "") + bindRunnerGitLabCredentials(inv, run, base, "gitlab-secret") + if inv.Repositories[0].GitToken != "gitlab-secret" || inv.Repositories[0].GitAuthBaseURL != base || inv.Repositories[1].GitToken != "github-secret" || inv.Repositories[1].GitAuthBaseURL != "" { + t.Fatal("runner credentials are not separated") + } +} + +func TestGitLabRunnerRequiresHostBoundCredentialSupport(t *testing.T) { + st, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, r := range []domain.Runner{{ID: "old", RunnerVersion: "0.7.0"}, {ID: "new", RunnerVersion: "0.8.0"}} { + if err := st.PutRunner(r); err != nil { + t.Fatal(err) + } + } + e := &Engine{repo: st} + repositories := []domain.RepositoryContext{{Provider: "gitlab"}} + if err := e.requireExistingWorkBranchRunner("old", repositories); err == nil { + t.Fatal("old runner accepted a GitLab task") + } + if err := e.requireExistingWorkBranchRunner("new", repositories); err != nil { + t.Fatal(err) + } +} diff --git a/internal/engine/integrate.go b/internal/engine/integrate.go index 0f707a6..7db4e10 100644 --- a/internal/engine/integrate.go +++ b/internal/engine/integrate.go @@ -66,6 +66,7 @@ func (e *Engine) integrateScheduledRun(id string) (bool, error) { return false, fmt.Errorf("run sandbox missing") } ctx := withGitIdentity(withGitToken(context.Background(), env.credentialSnapshot().GitToken), env.credentialSnapshot().GitUserName, env.credentialSnapshot().GitUserEmail) + ctx = withGitLabCredentials(ctx, env.credentialSnapshot().GitLabBaseURL, env.credentialSnapshot().GitLabToken) runner, onRunner, err := e.integrationRunner(r) if err != nil { return false, err @@ -95,10 +96,11 @@ func (e *Engine) integrateScheduledRun(id string) (bool, error) { return err } } else { + repoCtx := gitContextForRepository(ctx, rc) workspace := repositoryWorkspacePath(env.Workspace(), rc) - conflicts, err = integrateLocally(ctx, r, workspace, rc) + conflicts, err = integrateLocally(repoCtx, r, workspace, rc) pushDirect = func() error { - _, err := gitC(ctx, workspace, "push", "origin", "HEAD:refs/heads/"+b) + _, err := gitC(repoCtx, workspace, "push", "origin", "HEAD:refs/heads/"+b) return err } } diff --git a/internal/engine/node_execute.go b/internal/engine/node_execute.go index 4a464d5..0ff617e 100644 --- a/internal/engine/node_execute.go +++ b/internal/engine/node_execute.go @@ -442,6 +442,7 @@ func (e *Engine) executeNode(ctx context.Context, runID string, wf domain.Workfl current, _ = e.repo.RunState(runID) gitTok := sb.credentialSnapshot().GitToken ctx = withGitToken(ctx, gitTok) + ctx = withGitLabCredentials(ctx, sb.credentialSnapshot().GitLabBaseURL, sb.credentialSnapshot().GitLabToken) ctx = withGitIdentity(ctx, sb.credentialSnapshot().GitUserName, sb.credentialSnapshot().GitUserEmail) // Repository checkout and Agent-configuration index isolation belong to the // run environment, not to each node. The same checkout and .git/index are diff --git a/internal/engine/repository_workspace.go b/internal/engine/repository_workspace.go index 4213726..fa0f82c 100644 --- a/internal/engine/repository_workspace.go +++ b/internal/engine/repository_workspace.go @@ -57,7 +57,7 @@ func prepareRepositories(ctx context.Context, workspace string, repositories []d return nil } for _, rc := range repositories { - if err := prepareRepository(ctx, repositoryWorkspacePath(workspace, rc), rc); err != nil { + if err := prepareRepository(gitContextForRepository(ctx, rc), repositoryWorkspacePath(workspace, rc), rc); err != nil { return fmt.Errorf("repository %s: %w", rc.Name, err) } } @@ -160,7 +160,11 @@ func prepareRepository(ctx context.Context, workspace string, repository domain. } // Clone into the existing directory (`.`) so git cannot unlink and // recreate `workspace`. That inode is the Docker bind-mount source. - args = append(args, gitwork.RewriteRemoteForToken(remote, gitTokenFrom(ctx)), ".") + if _, gitLab := gitwork.HostCredentialFrom(ctx); gitLab { + args = append(args, remote, ".") + } else { + args = append(args, gitwork.RewriteRemoteForToken(remote, gitTokenFrom(ctx)), ".") + } if _, err := runGit(ctx, workspace, args...); err != nil { return fmt.Errorf("git clone: %w", err) } diff --git a/internal/engine/run_environment.go b/internal/engine/run_environment.go index 99cf9ac..56f70f7 100644 --- a/internal/engine/run_environment.go +++ b/internal/engine/run_environment.go @@ -227,6 +227,15 @@ func (e *Engine) resolveRunCredentials(run domain.Run) runtime.RunCredentials { credentials.GitUserEmail = identity.email if user, ok := e.repo.User(userID); ok { credentials.TaihuPAT = user.TaihuPAT + for _, rc := range run.RepositoryContexts() { + if rc.Provider == "gitlab" { + if _, ok := domain.ParseGitLabRepositoryURL(rc.CloneURL, user.GitLabBaseURL); ok { + credentials.GitLabToken = user.GitLabToken + credentials.GitLabBaseURL = user.GitLabBaseURL + } + break + } + } credentials.Codex = runtime.ProviderCredential{APIKey: user.CodexAPIKey, BaseURL: user.CodexBaseURL} credentials.ClaudeCode = runtime.ProviderCredential{APIKey: user.ClaudeCodeAPIKey, BaseURL: user.ClaudeCodeBaseURL} } diff --git a/internal/engine/runner_align.go b/internal/engine/runner_align.go index 9593fea..3e7ee80 100644 --- a/internal/engine/runner_align.go +++ b/internal/engine/runner_align.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strings" "time" @@ -24,6 +25,9 @@ func (e *Engine) AlignBaseline(repository domain.RepositoryContext, gitToken str if !ok { return AlignIntegrateBaseline(repository, gitToken, e.repo) } + if err := e.requireExistingWorkBranchRunner(runner.ID, []domain.RepositoryContext{repository}); err != nil { + return err + } unlock, err := lockIntegration(e.repo, repository.RepositoryID) if err != nil { return err @@ -48,6 +52,13 @@ func (e *Engine) AlignBaseline(repository domain.RepositoryContext, gitToken str DefaultBranch: strings.TrimSpace(repository.DefaultBranch), }}}, } + if repository.Provider == "gitlab" { + u, err := url.Parse(repository.CloneURL) + if err != nil || u.Host == "" { + return fmt.Errorf("GitLab 仓库地址无效") + } + task.Invocation.Repositories[0].GitAuthBaseURL = u.Scheme + "://" + u.Host + } if err := e.repo.PutRunnerTask(task); err != nil { return err } diff --git a/internal/engine/runner_dispatch.go b/internal/engine/runner_dispatch.go index cbe40e2..f8799da 100644 --- a/internal/engine/runner_dispatch.go +++ b/internal/engine/runner_dispatch.go @@ -118,15 +118,14 @@ func (e *Engine) requireExistingWorkBranchRunner(runnerID string, repositories [ if runnerID == "" { return nil } + runner, found := e.repo.Runner(runnerID) for _, repository := range repositories { - if !repository.RequireExistingWorkBranch { - continue + if repository.Provider == "gitlab" && (!found || !runner.SupportsGitLabCredentials()) { + return fmt.Errorf("GitLab 任务需要执行机器升级至 %s 或更新版本,请先更新 Runner", domain.RunnerVersionGitLabCredentials) } - runner, ok := e.repo.Runner(runnerID) - if !ok || !runner.SupportsExistingWorkBranch() { + if repository.RequireExistingWorkBranch && (!found || !runner.SupportsExistingWorkBranch()) { return fmt.Errorf("继续已有分支需要执行机器升级至 %s 或更新版本,请先更新 Runner", domain.RunnerVersionExistingWorkBranch) } - break } return nil } @@ -214,6 +213,7 @@ func (e *Engine) dispatchRunnerTask(ctx context.Context, runnerID string, run do } } bindRunnerRepositories(invocation, run, sb.credentialSnapshot().GitToken, sb.credentialSnapshot().GitUserName, sb.credentialSnapshot().GitUserEmail) + bindRunnerGitLabCredentials(invocation, run, sb.credentialSnapshot().GitLabBaseURL, sb.credentialSnapshot().GitLabToken) reported := sb.reportedOutputs(node.ID) if reported != nil { invocation.Artifacts = nil @@ -350,6 +350,32 @@ func bindRunnerRepositories(invocation *domain.RunnerInvocation, run domain.Run, } } +func bindRunnerGitLabCredentials(inv *domain.RunnerInvocation, run domain.Run, base, token string) { + if inv == nil { + return + } + apply := func(item *domain.RunnerRepository) { + if item == nil { + return + } + for _, rc := range run.RepositoryContexts() { + if rc.RepositoryID != item.RepositoryID || rc.Provider != "gitlab" { + continue + } + item.GitToken = "" + item.GitAuthBaseURL = base + if _, ok := domain.ParseGitLabRepositoryURL(rc.CloneURL, base); ok { + item.GitToken = token + } + return + } + } + apply(inv.Repository) + for i := range inv.Repositories { + apply(&inv.Repositories[i]) + } +} + func shellSingleQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } @@ -373,6 +399,7 @@ func (e *Engine) dispatchRunnerScript(ctx context.Context, runnerID string, run ArchivedFiles: e.legacyArchivedWorkspaceFiles(run, nodeID), Concurrent: run.WorkflowSnapshot.CollaborationMode == "group", RunID: run.ID, NodeID: nodeID, Artifacts: artifacts, ArtifactDirectory: artifactDirectory, Assets: assets, TaihuPAT: taihuPAT, MCPCallServers: assets.MCPServers, HumanContext: run.HumanContext} bindRunnerRepositories(invocation, run, gitToken, gitUserName, gitUserEmail) + bindRunnerGitLabCredentials(invocation, run, sb.credentialSnapshot().GitLabBaseURL, sb.credentialSnapshot().GitLabToken) reported := sb.reportedOutputs(nodeID) if reported != nil { invocation.Artifacts = nil @@ -643,6 +670,8 @@ func (e *Engine) gateCommandInvocation(run domain.Run, nodeID string) *domain.Ru } identity := resolveGitIdentity(e.repo, run.ExecutionContext.ResponsibleUserID) bindRunnerRepositories(invocation, run, resolveGitToken(e.repo, run.ExecutionContext.ResponsibleUserID), identity.name, identity.email) + cred := e.resolveRunCredentials(run) + bindRunnerGitLabCredentials(invocation, run, cred.GitLabBaseURL, cred.GitLabToken) return invocation } diff --git a/internal/engine/runner_integrate.go b/internal/engine/runner_integrate.go index f57d13d..0e0f55c 100644 --- a/internal/engine/runner_integrate.go +++ b/internal/engine/runner_integrate.go @@ -20,9 +20,14 @@ const integrationTaskNode = "_integrate" // pushIntegrate the runner also pushes the merged result to the integration // branch. It returns the conflicted files, if any. func (e *Engine) integrateOnRunner(ctx context.Context, runner domain.Runner, run domain.Run, rc domain.RepositoryContext, pushIntegrate bool) ([]string, error) { + if err := e.requireExistingWorkBranchRunner(runner.ID, run.RepositoryContexts()); err != nil { + return nil, err + } invocation := &domain.RunnerInvocation{RunID: run.ID, NodeID: integrationTaskNode} identity := gitIdentityFrom(ctx) bindRunnerRepositories(invocation, run, gitTokenFrom(ctx), identity.name, identity.email) + cred, _ := ctx.Value(gitLabCredentialsKey{}).(gitLabCredentials) + bindRunnerGitLabCredentials(invocation, run, cred.base, cred.token) invocation.Repository = nil var target *domain.RunnerRepository for i := range invocation.Repositories { diff --git a/internal/engine/work_branch_sync.go b/internal/engine/work_branch_sync.go index d08c8c9..6713cc9 100644 --- a/internal/engine/work_branch_sync.go +++ b/internal/engine/work_branch_sync.go @@ -68,7 +68,7 @@ func (e *Engine) syncWorkBranches(ctx context.Context, runID string, node domain return e.runnerWorkBranchSync(ctx, runnerID, run, node, domain.RunnerTaskActionPushWorkBranch, message) } for _, rc := range run.RepositoryContexts() { - head, err := syncWorkBranch(ctx, repositoryWorkspacePath(workspace, rc), rc, message) + head, err := syncWorkBranch(gitContextForRepository(ctx, rc), repositoryWorkspacePath(workspace, rc), rc, message) if err != nil { return fmt.Errorf("%s: %w", rc.Name, err) } @@ -86,7 +86,7 @@ func (e *Engine) pullWorkBranches(ctx context.Context, runID string, node domain return e.runnerWorkBranchSync(ctx, runnerID, run, node, domain.RunnerTaskActionPullWorkBranch, "") } for _, rc := range run.RepositoryContexts() { - head, err := pullWorkBranch(ctx, repositoryWorkspacePath(workspace, rc), rc) + head, err := pullWorkBranch(gitContextForRepository(ctx, rc), repositoryWorkspacePath(workspace, rc), rc) if err != nil { return fmt.Errorf("%s: %w", rc.Name, err) } @@ -102,6 +102,9 @@ func (e *Engine) pullWorkBranches(ctx context.Context, runID string, node domain // RunnerVersionWorkBranchSync keep the previous behaviour (no sync) so an // outdated binary is never handed an action it would misinterpret. func (e *Engine) runnerWorkBranchSync(ctx context.Context, runnerID string, run domain.Run, node domain.WorkflowNode, action, message string) error { + if err := e.requireExistingWorkBranchRunner(runnerID, run.RepositoryContexts()); err != nil { + return err + } runner, ok := e.repo.Runner(runnerID) if !ok { return fmt.Errorf("执行机器 %s 不存在", runnerID) @@ -109,6 +112,8 @@ func (e *Engine) runnerWorkBranchSync(ctx context.Context, runnerID string, run invocation := &domain.RunnerInvocation{RunID: run.ID, NodeID: node.ID} identity := gitIdentityFrom(ctx) bindRunnerRepositories(invocation, run, gitTokenFrom(ctx), identity.name, identity.email) + cred, _ := ctx.Value(gitLabCredentialsKey{}).(gitLabCredentials) + bindRunnerGitLabCredentials(invocation, run, cred.base, cred.token) syncable := false for _, repository := range invocation.Repositories { syncable = syncable || repository.SyncWorkBranch diff --git a/internal/engine/workspace_preparation.go b/internal/engine/workspace_preparation.go index 901cf0b..e1e52b4 100644 --- a/internal/engine/workspace_preparation.go +++ b/internal/engine/workspace_preparation.go @@ -116,6 +116,7 @@ func (e *Engine) preparedWorkspaceRequest(run domain.Run, request domain.RunnerW credentials := e.resolveRunCredentials(run) invocation := &domain.RunnerInvocation{} bindRunnerRepositories(invocation, run, credentials.GitToken, credentials.GitUserName, credentials.GitUserEmail) + bindRunnerGitLabCredentials(invocation, run, credentials.GitLabBaseURL, credentials.GitLabToken) assets := e.resolveScriptMCPAssets(run.ExecutionContext.ProjectID, run.ExecutionContext.ResponsibleUserID) invocation.Assets = assets invocation.MCPCallServers = assets.MCPServers diff --git a/internal/gitwork/auth.go b/internal/gitwork/auth.go index 20181b4..783a5d7 100644 --- a/internal/gitwork/auth.go +++ b/internal/gitwork/auth.go @@ -215,3 +215,30 @@ func Redact(s string, tokens ...string) string { } return s } + +// HostCredential binds an HTTP credential to one explicitly configured origin. +type HostCredential struct{ BaseURL, Token string } +type hostCredentialKey struct{} + +func WithHostCredential(ctx context.Context, baseURL, token string) context.Context { + return context.WithValue(ctx, hostCredentialKey{}, HostCredential{BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), Token: strings.TrimSpace(token)}) +} +func HostCredentialFrom(ctx context.Context) (HostCredential, bool) { + if ctx == nil { + return HostCredential{}, false + } + v, ok := ctx.Value(hostCredentialKey{}).(HostCredential) + return v, ok +} +func hostAuthConfigArgs(c HostCredential) []string { + if c.BaseURL == "" || c.Token == "" { + return []string{"-c", "credential.helper=", "-c", "core.askPass="} + } + return []string{"-c", "credential.helper=", "-c", "credential." + c.BaseURL + ".helper=" + credentialHelper(), "-c", "http." + c.BaseURL + ".followRedirects=false"} +} +func hostEnv(c HostCredential) []string { + env := Env("") + env = WithEnvValue(env, gitHTTPUserEnv, "oauth2") + env = WithEnvValue(env, gitHTTPPassEnv, c.Token) + return env +} diff --git a/internal/gitwork/git.go b/internal/gitwork/git.go index c92c3ee..6b26a50 100644 --- a/internal/gitwork/git.go +++ b/internal/gitwork/git.go @@ -13,9 +13,16 @@ import ( func Run(ctx context.Context, workspace string, args ...string) (string, error) { token := TokenFrom(ctx) dir := CommandDir(workspace) - cmd := exec.CommandContext(ctx, "git", append(AuthConfigArgs(token), args...)...) + authArgs := AuthConfigArgs(token) + env := Env(token) + if hostCredential, ok := HostCredentialFrom(ctx); ok { + authArgs = hostAuthConfigArgs(hostCredential) + env = hostEnv(hostCredential) + token = hostCredential.Token + } + cmd := exec.CommandContext(ctx, "git", append(authArgs, args...)...) cmd.Dir = dir - cmd.Env = WithEnvValue(Env(token), "PWD", dir) + cmd.Env = WithEnvValue(env, "PWD", dir) // A run without a checkout must not discover the platform's own .git // when the operator keeps workspaces below a source checkout. if absolute, err := filepath.Abs(dir); err == nil { diff --git a/internal/gitwork/gitlab_live_test.go b/internal/gitwork/gitlab_live_test.go new file mode 100644 index 0000000..a52129b --- /dev/null +++ b/internal/gitwork/gitlab_live_test.go @@ -0,0 +1,24 @@ +package gitwork + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestGitLabLiveReadOnly(t *testing.T) { + token, remote := os.Getenv("LOOPFORGE_TEST_GITLAB_TOKEN"), os.Getenv("LOOPFORGE_TEST_GITLAB_REMOTE") + if token == "" || remote == "" { + t.Skip("explicit live test not configured") + } + base := os.Getenv("LOOPFORGE_TEST_GITLAB_BASE") + ctx := WithHostCredential(context.Background(), base, token) + out, err := Run(ctx, t.TempDir(), "ls-remote", "--heads", remote) + if err != nil { + t.Fatalf("ls-remote failed: %v", err) + } + if strings.TrimSpace(out) == "" { + t.Fatal("no branches returned") + } +} diff --git a/internal/gitwork/host_auth_test.go b/internal/gitwork/host_auth_test.go new file mode 100644 index 0000000..8962611 --- /dev/null +++ b/internal/gitwork/host_auth_test.go @@ -0,0 +1,37 @@ +package gitwork + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHostCredentialOnlySentToConfiguredOrigin(t *testing.T) { + var gotConfigured, gotOther string + a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, pass, ok := r.BasicAuth(); ok { + gotConfigured = pass + } + w.Header().Set("WWW-Authenticate", `Basic realm="git"`) + w.WriteHeader(401) + })) + defer a.Close() + b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, pass, ok := r.BasicAuth(); ok { + gotOther = pass + } + w.Header().Set("WWW-Authenticate", `Basic realm="git"`) + w.WriteHeader(401) + })) + defer b.Close() + ctx := WithHostCredential(context.Background(), a.URL, "unique-test-token") + _, _ = Run(ctx, t.TempDir(), "ls-remote", a.URL+"/team/repo.git") + _, _ = Run(ctx, t.TempDir(), "ls-remote", b.URL+"/team/repo.git") + if gotConfigured != "unique-test-token" { + t.Error("configured host did not receive credential") + } + if gotOther != "" { + t.Error("credential sent to different origin") + } +} diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index c1d68e0..db6108e 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -108,10 +108,25 @@ func (h *catalogHandlers) repositories(c *gin.Context) { } // One pasted repository reference (web / SSH / clone / git clone / group/repo) // is enough. Prefer repoUrl so an SSH paste keeps an SSH clone URL. + gitLabBase := "" + if project, found := h.repo.Project(v.ProjectID); found { + if owner, found := h.repo.User(project.OwnerID); found { + gitLabBase = owner.GitLabBaseURL + } + } + parseRepository := func(raw string) (domain.GongfengRepoInfo, bool) { + if info, ok := domain.ParseRepositoryURL(raw); ok { + return info, true + } + if gitLabBase != "" { + return domain.ParseGitLabRepositoryURL(raw, gitLabBase) + } + return domain.GongfengRepoInfo{}, false + } var parsed domain.GongfengRepoInfo var parsedOK bool for _, candidate := range []string{strings.TrimSpace(body.RepoURL), strings.TrimSpace(body.URL), v.CloneURL, v.WebURL, v.GongfengProjectID} { - if info, ok := domain.ParseRepositoryURL(candidate); ok { + if info, ok := parseRepository(candidate); ok { parsed, parsedOK = info, true break } @@ -123,13 +138,13 @@ func (h *catalogHandlers) repositories(c *gin.Context) { primary = strings.TrimSpace(body.URL) } if primary != "" { - info, ok := domain.ParseRepositoryURL(primary) + info, ok := parseRepository(primary) if !ok { c.JSON(400, gin.H{"error": "仓库 URL 无法识别,请提供有效的 GitHub 仓库地址"}) return } if body.RepoURL != "" && body.URL != "" { - other, ok := domain.ParseRepositoryURL(body.URL) + other, ok := parseRepository(body.URL) if !ok || other.Project != info.Project { c.JSON(400, gin.H{"error": "repoUrl 与 url 指向不同仓库"}) return @@ -144,7 +159,7 @@ func (h *catalogHandlers) repositories(c *gin.Context) { // is rejected loudly: storing it as a project path would silently break // sync, branching and MR calls. Bare paths and numeric ids stay compatible. if v.GongfengProjectID != "" { - if info, ok := domain.ParseRepositoryURL(v.GongfengProjectID); ok { + if info, ok := parseRepository(v.GongfengProjectID); ok { v.GongfengProjectID = info.Project } else if !strings.HasPrefix(v.GongfengProjectID, "github:") && !domain.IsSafeGongfengProjectID(v.GongfengProjectID) { c.JSON(400, map[string]string{"error": "仓库链接无法识别:请提供有效的 GitHub 仓库地址"}) @@ -190,11 +205,14 @@ func (h *catalogHandlers) repositories(c *gin.Context) { v.DefaultBranch = "main" } } + if strings.HasPrefix(v.GongfengProjectID, "gitlab:") { + v.Provider = "gitlab" + } if v.Provider == "" { v.Provider = "gongfeng" } - if v.Provider != "gongfeng" && v.Provider != "github" { - c.JSON(400, map[string]string{"error": "provider must be github or gongfeng"}) + if v.Provider != "gongfeng" && v.Provider != "github" && v.Provider != "gitlab" { + c.JSON(400, map[string]string{"error": "provider must be github, gitlab or gongfeng"}) return } if v.DefaultBranch == "" { diff --git a/internal/httpapi/git_token_verify.go b/internal/httpapi/git_token_verify.go index aed9f91..e2e9bcb 100644 --- a/internal/httpapi/git_token_verify.go +++ b/internal/httpapi/git_token_verify.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -176,3 +177,53 @@ func missingGitTokenScopes(scopes, required []string) []string { } return missing } + +// verifyGitLabToken validates the configured user's identity on their instance. +func (h *usersHandlers) verifyGitLabToken(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gitTokenVerifyResult{Error: "valid user id is required"}) + return + } + current, ok := h.requestUser(c.Request, false) + if !ok || current.ID != id { + c.JSON(http.StatusForbidden, gitTokenVerifyResult{Error: "只能校验当前登录用户的凭据"}) + return + } + if h.repo == nil { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: "存储不可用"}) + return + } + user, ok := h.repo.User(id) + if !ok || user.GitLabToken == "" || user.GitLabBaseURL == "" { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: "请先保存 GitLab 地址和 Token"}) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(user.GitLabBaseURL, "/")+"/api/v4/user", nil) + if err != nil { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: "GitLab 地址无效"}) + return + } + req.Header.Set("PRIVATE-TOKEN", user.GitLabToken) + client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + res, err := client.Do(req) + if err != nil { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: "无法连接 GitLab"}) + return + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: fmt.Sprintf("GitLab 返回 HTTP %d,请检查 Token", res.StatusCode)}) + return + } + var identity struct { + Username string `json:"username"` + } + if json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&identity) != nil || identity.Username == "" { + c.JSON(http.StatusOK, gitTokenVerifyResult{Error: "GitLab 响应无效"}) + return + } + c.JSON(http.StatusOK, gitTokenVerifyResult{Valid: true, Login: identity.Username}) +} diff --git a/internal/httpapi/gitlab_repository_test.go b/internal/httpapi/gitlab_repository_test.go new file mode 100644 index 0000000..6b6a04d --- /dev/null +++ b/internal/httpapi/gitlab_repository_test.go @@ -0,0 +1,33 @@ +package httpapi + +import ( + "agentflow/internal/domain" + "agentflow/internal/engine" + "agentflow/internal/repo" + "agentflow/internal/runtime" + "agentflow/internal/tracker" + "net/http" + "path/filepath" + "testing" +) + +func TestGitLabRepositoryBindingRequiresConfiguredOrigin(t *testing.T) { + s, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.PutUser(domain.User{LoginName: "gitlab-owner", GitLabBaseURL: "http://git.example.test:9530", GitLabToken: "test-token"}); err != nil { + t.Fatal(err) + } + u, _ := s.UserByLoginName("gitlab-owner") + if err := s.PutProject(domain.Project{ID: "p", Name: "P", OwnerID: u.ID}); err != nil { + t.Fatal(err) + } + h := New(s, engine.New(s, runtime.NewManager(filepath.Join(t.TempDir(), "workspaces"))), tracker.NewService(), "", "") + saved := requestJSON[domain.Repository](t, h, http.MethodPost, "/api/repositories", map[string]any{"projectId": "p", "repoUrl": "http://git.example.test:9530/group/sub/repo.git"}, http.StatusOK) + if saved.Provider != "gitlab" || saved.GongfengProjectID != "gitlab:http://git.example.test:9530/group/sub/repo" { + t.Fatalf("wrong repository: %+v", saved) + } + requestJSON[map[string]any](t, h, http.MethodPost, "/api/repositories", map[string]any{"projectId": "p", "repoUrl": "http://git.example.test.attacker:9530/group/repo.git"}, http.StatusBadRequest) +} diff --git a/internal/httpapi/schedule.go b/internal/httpapi/schedule.go index 3f5a92f..b3cd25a 100644 --- a/internal/httpapi/schedule.go +++ b/internal/httpapi/schedule.go @@ -213,7 +213,7 @@ func (h *scheduleHandlers) guardScheduledBaseline(job domain.ScheduleJob, issues } continue } - err := h.engine.AlignBaseline(ctx, engine.ResolveClarifyGitToken(h.repo, responsible.ID)) + err := h.engine.AlignBaseline(ctx, engine.ResolveClarifyGitTokenForRepository(h.repo, responsible.ID, repo)) if err == nil { h.cancelStaleAlignRuns(repo.ID) continue diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 696524f..c6757d9 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -237,6 +237,7 @@ func newWithNotifications(s *repo.Store, e *engine.Engine, syncSvc *tracker.Serv }) // 工蜂 Git Token 校验:只允许当前登录用户校验自己的令牌。 g.GET("/api/users/:id/git-token/verify", api.users.verifyGitToken) + g.GET("/api/users/:id/gitlab-token/verify", api.users.verifyGitLabToken) g.Any("/api/users/:id", func(c *gin.Context) { c.JSON(http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) }) diff --git a/internal/httpapi/stream.go b/internal/httpapi/stream.go index e0ad1ad..5657b38 100644 --- a/internal/httpapi/stream.go +++ b/internal/httpapi/stream.go @@ -323,7 +323,7 @@ func (h *streamHandlers) prepareClarifyRepo(ctx context.Context, repo domain.Rep return cacheDir, true, "" } - token := engine.ResolveClarifyGitToken(h.repo, responsibleUserID) + token := engine.ResolveClarifyGitTokenForRepository(h.repo, responsibleUserID, repo) prepCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() diff --git a/internal/httpapi/users.go b/internal/httpapi/users.go index 760b798..dceb77e 100644 --- a/internal/httpapi/users.go +++ b/internal/httpapi/users.go @@ -39,7 +39,7 @@ func (h *usersHandlers) user(c *gin.Context) { if !decode(c, &body) { return } - for _, raw := range []*string{body.CodexBaseURL, body.ClaudeCodeBaseURL} { + for _, raw := range []*string{body.CodexBaseURL, body.ClaudeCodeBaseURL, body.GitLabBaseURL} { if raw == nil || strings.TrimSpace(*raw) == "" { continue } diff --git a/internal/repo/models.go b/internal/repo/models.go index 7eebc23..f2cfd90 100644 --- a/internal/repo/models.go +++ b/internal/repo/models.go @@ -36,6 +36,8 @@ type userConfigRow struct { UserID int64 `gorm:"column:user_id;primaryKey"` TaihuPAT *string `gorm:"column:taihu_pat;type:text"` GitToken *string `gorm:"column:git_token;type:text"` + GitLabToken *string `gorm:"column:gitlab_token;type:text"` + GitLabBaseURL *string `gorm:"column:gitlab_base_url;type:text"` CodeBuddyAPIKey *string `gorm:"column:codebuddy_api_key;type:text"` CodeBuddyInternetEnvironment string `gorm:"column:codebuddy_internet_environment;size:64"` CodexAPIKey *string `gorm:"column:codex_api_key;type:text"` diff --git a/internal/repo/provider_credentials_test.go b/internal/repo/provider_credentials_test.go index 41bd377..030a8f2 100644 --- a/internal/repo/provider_credentials_test.go +++ b/internal/repo/provider_credentials_test.go @@ -86,3 +86,49 @@ func TestProviderUserCredentials(t *testing.T) { }) } } + +func TestGitLabCredentialsKeepHostsBound(t *testing.T) { + st, err := OpenTest() + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.PutUser(domain.User{LoginName: "gitlab-user"}); err != nil { + t.Fatal(err) + } + u, ok := st.UserByLoginName("gitlab-user") + if !ok { + t.Fatal("missing user") + } + base := "http://git.example.invalid:9530" + if _, err := st.UpdateUserCredentials(u.ID, UserCredentialUpdate{GitToken: "github-secret", GitLabToken: "gitlab-secret", GitLabBaseURL: &base}); err != nil { + t.Fatal(err) + } + if err := st.PutUser(domain.User{ID: u.ID, LoginName: u.LoginName}); err != nil { + t.Fatal(err) + } + got, _ := st.User(u.ID) + if got.GitToken != "github-secret" || got.GitLabToken != "gitlab-secret" || got.GitLabBaseURL != base { + t.Fatal("credential not preserved") + } + pub := domain.PublicUser(got) + b, _ := json.Marshal(pub) + if strings.Contains(string(b), "secret") || !pub.GitLabTokenConfigured { + t.Fatal("credential exposed") + } + other := "http://other.example.invalid" + if _, err := st.UpdateUserCredentials(u.ID, UserCredentialUpdate{GitLabBaseURL: &other}); err == nil { + t.Fatal("changing host without replacing or clearing token should fail") + } + got, _ = st.User(u.ID) + if got.GitLabBaseURL != base { + t.Fatal("host changed despite rejection") + } + if _, err := st.UpdateUserCredentials(u.ID, UserCredentialUpdate{GitLabBaseURL: &other, GitLabTokenClear: true}); err != nil { + t.Fatal(err) + } + got, _ = st.User(u.ID) + if got.GitLabBaseURL != other || got.GitLabToken != "" || got.GitToken != "github-secret" { + t.Fatal("clear changed unrelated credential") + } +} diff --git a/internal/repo/users.go b/internal/repo/users.go index 191106a..487a118 100644 --- a/internal/repo/users.go +++ b/internal/repo/users.go @@ -21,6 +21,8 @@ type userWithConfig struct { PlatformRole string `gorm:"column:platform_role"` TaihuPAT *string `gorm:"column:taihu_pat"` GitToken *string `gorm:"column:git_token"` + GitLabToken *string `gorm:"column:gitlab_token"` + GitLabBaseURL *string `gorm:"column:gitlab_base_url"` CodeBuddyAPIKey *string `gorm:"column:codebuddy_api_key"` CodeBuddyInternetEnvironment string `gorm:"column:codebuddy_internet_environment"` CodexAPIKey *string `gorm:"column:codex_api_key"` @@ -33,7 +35,7 @@ type userWithConfig struct { const userSelectColumns = `u.id, u.staff_id, u.login_name, u.chinese_name, u.dept_id, u.dept_name, COALESCE(u.platform_role, '') AS platform_role, - c.taihu_pat, c.git_token, c.codebuddy_api_key, COALESCE(c.codebuddy_internet_environment, '') AS codebuddy_internet_environment, + c.taihu_pat, c.git_token, c.gitlab_token, c.gitlab_base_url, c.codebuddy_api_key, COALESCE(c.codebuddy_internet_environment, '') AS codebuddy_internet_environment, c.codex_api_key, c.codex_base_url, c.claude_code_api_key, c.claude_code_base_url, u.created_at, u.updated_at` @@ -49,6 +51,12 @@ func (r userWithConfig) toDomain() domain.User { if r.GitToken != nil { u.GitToken = *r.GitToken } + if r.GitLabToken != nil { + u.GitLabToken = *r.GitLabToken + } + if r.GitLabBaseURL != nil { + u.GitLabBaseURL = *r.GitLabBaseURL + } if r.CodeBuddyAPIKey != nil { u.CodeBuddyAPIKey = *r.CodeBuddyAPIKey } @@ -135,6 +143,7 @@ func (r *UsersRepo) PutUser(v domain.User) error { // through UpdateUserCredentials and must survive every login refresh. v.TaihuPAT = old.TaihuPAT v.GitToken = old.GitToken + v.GitLabToken, v.GitLabBaseURL = old.GitLabToken, old.GitLabBaseURL v.CodexAPIKey, v.CodexBaseURL = old.CodexAPIKey, old.CodexBaseURL v.ClaudeCodeAPIKey, v.ClaudeCodeBaseURL = old.ClaudeCodeAPIKey, old.ClaudeCodeBaseURL v.CodeBuddyAPIKey = old.CodeBuddyAPIKey @@ -203,7 +212,7 @@ func (r *UsersRepo) writeUser(v domain.User) (domain.User, error) { func userHasConfig(user domain.User) bool { return user.TaihuPAT != "" || - user.GitToken != "" || + user.GitToken != "" || user.GitLabToken != "" || user.GitLabBaseURL != "" || user.CodeBuddyAPIKey != "" || user.CodeBuddyInternetEnvironment != "" || user.CodexAPIKey != "" || @@ -217,6 +226,9 @@ type UserCredentialUpdate struct { TaihuPATClear bool `json:"taihuPatClear"` GitToken string `json:"gitToken"` GitTokenClear bool `json:"gitTokenClear"` + GitLabToken string `json:"gitLabToken"` + GitLabTokenClear bool `json:"gitLabTokenClear"` + GitLabBaseURL *string `json:"gitLabBaseUrl"` CodeBuddyAPIKey string `json:"codeBuddyApiKey"` CodeBuddyAPIKeyClear bool `json:"codeBuddyApiKeyClear"` CodeBuddyInternetEnvironment *string `json:"codeBuddyInternetEnvironment"` @@ -249,6 +261,18 @@ func (r *UsersRepo) UpdateUserCredentials(id int64, update UserCredentialUpdate) } else if value := strings.TrimSpace(update.GitToken); value != "" { user.GitToken = value } + if update.GitLabTokenClear { + user.GitLabToken = "" + } else if value := strings.TrimSpace(update.GitLabToken); value != "" { + user.GitLabToken = value + } + if update.GitLabBaseURL != nil { + newURL := strings.TrimRight(strings.TrimSpace(*update.GitLabBaseURL), "/") + if newURL != user.GitLabBaseURL && user.GitLabToken != "" && strings.TrimSpace(update.GitLabToken) == "" && !update.GitLabTokenClear { + return domain.User{}, errors.New("更换 GitLab 地址时请重新输入或清除 Token") + } + user.GitLabBaseURL = newURL + } if update.CodeBuddyAPIKeyClear { user.CodeBuddyAPIKey = "" } else if value := strings.TrimSpace(update.CodeBuddyAPIKey); value != "" { @@ -284,6 +308,7 @@ func (r *UsersRepo) writeUserConfig(v domain.User) error { return upsert(r.DB, "user_configs", map[string]any{ "user_id": v.ID, "taihu_pat": nullableString(v.TaihuPAT), "git_token": nullableString(v.GitToken), + "gitlab_token": nullableString(v.GitLabToken), "gitlab_base_url": nullableString(v.GitLabBaseURL), "codebuddy_api_key": nullableString(v.CodeBuddyAPIKey), "codebuddy_internet_environment": v.CodeBuddyInternetEnvironment, "codex_api_key": nullableString(v.CodexAPIKey), "codex_base_url": nullableString(v.CodexBaseURL), diff --git a/internal/runner/align.go b/internal/runner/align.go index 443dcc9..54c734a 100644 --- a/internal/runner/align.go +++ b/internal/runner/align.go @@ -47,7 +47,7 @@ func alignBaseline(ctx context.Context, dir string, inv *domain.RunnerInvocation if err := checkoutRepository(ctx, dir, &clone); err != nil { return domain.RunnerAlignResult{}, err } - repoCtx := gitwork.WithToken(ctx, repo.GitToken) + repoCtx := gitContextForRunnerRepository(ctx, &repo) gitwork.EnsureIdentity(repoCtx, dir) conflicts, err := gitwork.MergeLatestRemoteBranch(repoCtx, dir, def) if err != nil { diff --git a/internal/runner/daemon.go b/internal/runner/daemon.go index 03e509d..3b63fd9 100644 --- a/internal/runner/daemon.go +++ b/internal/runner/daemon.go @@ -737,6 +737,9 @@ func (d *Daemon) executeTerminal(ctx context.Context, task domain.RunnerTask) ex if err != nil { return execResult{status: domain.RunnerTaskFailed, errMsg: fmt.Sprintf("prepare git credentials: %v", err)} } + if err := bindInvocationGitLab(gitEnv, task.Invocation); err != nil { + return execResult{status: domain.RunnerTaskFailed, errMsg: err.Error()} + } command = runtime.WithCommandEnvironment(command, gitEnv) } var result execResult @@ -867,6 +870,9 @@ func (d *Daemon) executePreparedInvocation(ctx context.Context, task domain.Runn if gitErr != nil { return execResult{status: domain.RunnerTaskFailed, errMsg: fmt.Sprintf("prepare git credentials: %v", gitErr)}, nil } + if err := bindInvocationGitLab(gitEnv, inv); err != nil { + return execResult{status: domain.RunnerTaskFailed, errMsg: err.Error()}, nil + } for key, value := range gitEnv { commandEnv[key] = value } @@ -1064,11 +1070,11 @@ func invocationGitToken(inv *domain.RunnerInvocation) string { return "" } for _, repo := range inv.Repositories { - if token := strings.TrimSpace(repo.GitToken); token != "" { + if token := strings.TrimSpace(repo.GitToken); repo.GitAuthBaseURL == "" && token != "" { return token } } - if inv.Repository != nil { + if inv.Repository != nil && inv.Repository.GitAuthBaseURL == "" { return strings.TrimSpace(inv.Repository.GitToken) } return "" @@ -1182,16 +1188,22 @@ func checkoutRepository(ctx context.Context, workspace string, repo *domain.Runn url := strings.TrimSpace(repo.CloneURL) // `-c` auth is scoped to this command, so origin stays the clean URL and // the token never lands in .git/config. - args := append(gitwork.AuthConfigArgs(repo.GitToken), "clone", "--quiet") + args := []string{"clone", "--quiet"} if ref := strings.TrimSpace(repo.Ref); ref != "" { args = append(args, "--branch", ref) } args = append(args, url, ".") - cmd := exec.CommandContext(cloneCtx, "git", args...) - cmd.Dir = workspace - cmd.Env = gitwork.Env(repo.GitToken) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git clone %s: %v (%s)", repo.CloneURL, err, cloneOutput(out, repo.GitToken)) + if repo.GitAuthBaseURL != "" { + if _, err := gitwork.Run(gitContextForRunnerRepository(cloneCtx, repo), workspace, args...); err != nil { + return fmt.Errorf("git clone %s: %w", repo.CloneURL, err) + } + } else { + cmd := exec.CommandContext(cloneCtx, "git", append(gitwork.AuthConfigArgs(repo.GitToken), args...)...) + cmd.Dir = workspace + cmd.Env = gitwork.Env(repo.GitToken) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone %s: %v (%s)", repo.CloneURL, err, cloneOutput(out, repo.GitToken)) + } } if repo.RequireExistingWorkBranch { return checkoutExistingRepositoryBranch(cloneCtx, workspace, repo) @@ -1217,7 +1229,7 @@ func checkoutExistingRepositoryBranch(ctx context.Context, workspace string, rep if branch == "" { return fmt.Errorf("继续已有分支时必须指定工作分支") } - repoCtx := gitwork.WithToken(ctx, repo.GitToken) + repoCtx := gitContextForRunnerRepository(ctx, repo) trackingRef := "refs/remotes/origin/" + branch if _, err := gitwork.C(repoCtx, workspace, "fetch", "--no-tags", "origin", "+refs/heads/"+branch+":"+trackingRef); err != nil { return fmt.Errorf("无法读取已有远端分支 %s,请确认分支存在且有访问权限: %w", branch, err) @@ -1281,7 +1293,37 @@ func (d *Daemon) defaultWorkspace(taskID string) string { } // runnerVersion is reported at registration; it matches the module build. -var runnerVersion = "0.7.0" +var runnerVersion = "0.8.0" // Version returns the runner build version reported at registration. func Version() string { return runnerVersion } + +func gitContextForRunnerRepository(ctx context.Context, r *domain.RunnerRepository) context.Context { + if r.GitAuthBaseURL != "" { + if _, ok := domain.ParseGitLabRepositoryURL(r.CloneURL, r.GitAuthBaseURL); !ok { + return gitwork.WithHostCredential(ctx, "", "") + } + return gitwork.WithHostCredential(ctx, r.GitAuthBaseURL, r.GitToken) + } + return gitwork.WithToken(ctx, r.GitToken) +} + +func bindInvocationGitLab(env map[string]string, inv *domain.RunnerInvocation) error { + if inv == nil { + return nil + } + var base, token string + for _, r := range inv.Repositories { + if r.GitAuthBaseURL == "" { + continue + } + if base != "" && (base != r.GitAuthBaseURL || token != r.GitToken) { + return fmt.Errorf("单个任务暂不支持多个 GitLab 凭据") + } + base, token = r.GitAuthBaseURL, r.GitToken + } + if base == "" && inv.Repository != nil { + base, token = inv.Repository.GitAuthBaseURL, inv.Repository.GitToken + } + return runtime.BindGitLabEnvironment(env, base, token) +} diff --git a/internal/runner/integrate.go b/internal/runner/integrate.go index 9d97cc0..a89ef01 100644 --- a/internal/runner/integrate.go +++ b/internal/runner/integrate.go @@ -54,7 +54,7 @@ func integrateTaskRepositories(ctx context.Context, workspace string, inv *domai if rel := safeRunnerRepoDir(repo.Dir); rel != "" { dir = filepath.Join(workspace, rel) } - repoCtx := gitwork.WithToken(ctx, repo.GitToken) + repoCtx := gitContextForRunnerRepository(ctx, &repo) gitwork.EnsureIdentity(repoCtx, dir) result := domain.RunnerIntegrationResult{RepositoryID: repo.RepositoryID, Name: repo.Name, WorkBranch: work, IntegrateBranch: integrate} conflicts, err := gitwork.MergeLatestIntegration(repoCtx, dir, work, integrate, label) diff --git a/internal/runner/work_branch.go b/internal/runner/work_branch.go index 1bfef9c..21e2d70 100644 --- a/internal/runner/work_branch.go +++ b/internal/runner/work_branch.go @@ -56,7 +56,7 @@ func syncTaskWorkBranches(ctx context.Context, workspace string, inv *domain.Run if rel := safeRunnerRepoDir(repo.Dir); rel != "" { dir = filepath.Join(workspace, rel) } - repoCtx := gitwork.WithToken(ctx, repo.GitToken) + repoCtx := gitContextForRunnerRepository(ctx, &repo) var head string var err error if repo.RequireExistingWorkBranch { diff --git a/internal/runner/workspace_preparation.go b/internal/runner/workspace_preparation.go index 7a6e52b..45ecad5 100644 --- a/internal/runner/workspace_preparation.go +++ b/internal/runner/workspace_preparation.go @@ -152,6 +152,13 @@ func (d *Daemon) workspaceExecutionSandbox(ctx context.Context, sb runtime.Sandb inv := request.PreparationInvocation if inv != nil { credentials.GitToken = invocationGitToken(inv) + for _, r := range inv.Repositories { + if r.GitAuthBaseURL != "" { + credentials.GitLabBaseURL = r.GitAuthBaseURL + credentials.GitLabToken = r.GitToken + break + } + } credentials.GitUserName = inv.GitUserName credentials.GitUserEmail = inv.GitUserEmail credentials.TaihuPAT = inv.TaihuPAT @@ -226,6 +233,13 @@ func (d *Daemon) openBusyGroupTerminal(ctx context.Context, root, scope string, credentials := runtime.RunCredentials{} if inv := request.PreparationInvocation; inv != nil { credentials.GitToken = invocationGitToken(inv) + for _, r := range inv.Repositories { + if r.GitAuthBaseURL != "" { + credentials.GitLabBaseURL = r.GitAuthBaseURL + credentials.GitLabToken = r.GitToken + break + } + } credentials.GitUserName = inv.GitUserName credentials.GitUserEmail = inv.GitUserEmail } diff --git a/internal/runtime/command_binding.go b/internal/runtime/command_binding.go index 1ae7f99..ad69802 100644 --- a/internal/runtime/command_binding.go +++ b/internal/runtime/command_binding.go @@ -41,6 +41,7 @@ func bindCommand(sandbox Sandbox, workspace string, command CommandSpec, credent bound.environment["CODEBUDDY_API_KEY"] = "" bound.environment["CODEBUDDY_INTERNET_ENVIRONMENT"] = "" bound.environment["AGENTFLOW_GIT_TOKEN"] = "" + bound.environment["AGENTFLOW_GITLAB_TOKEN"] = "" for _, key := range []string{"OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"} { bound.environment[key] = "" } @@ -58,6 +59,9 @@ func bindCommand(sandbox Sandbox, workspace string, command CommandSpec, credent if err := bindGitEnvironment(sandbox, workspace, bound.environment, credentials.GitToken); err != nil { return CommandSpec{}, err } + if err := BindGitLabEnvironment(bound.environment, credentials.GitLabBaseURL, credentials.GitLabToken); err != nil { + return CommandSpec{}, err + } case BindMCP: if err := bindMCPCallEnvironment(sandbox, workspace, bound.environment, credentials.MCPServers); err != nil { return CommandSpec{}, err @@ -209,3 +213,30 @@ func writeGitAskPass(workspace, path string) error { } return nil } + +// BindGitLabEnvironment configures a helper scoped to one GitLab origin; the token remains in the process environment. +func BindGitLabEnvironment(env map[string]string, base, token string) error { + base = strings.TrimRight(strings.TrimSpace(base), "/") + token = strings.TrimSpace(token) + if base == "" { + return nil + } + if token == "" { + return fmt.Errorf("需求责任人未配置该 GitLab 的 Token") + } + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("GitLab 地址无效") + } + n, _ := strconv.Atoi(env["GIT_CONFIG_COUNT"]) + helper := `!f() { test "$1" = get || exit 0; printf 'username=oauth2\npassword=%s\n' "$AGENTFLOW_GITLAB_TOKEN"; }; f` + env[fmt.Sprintf("GIT_CONFIG_KEY_%d", n)] = "credential." + base + ".helper" + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", n)] = helper + n++ + env[fmt.Sprintf("GIT_CONFIG_KEY_%d", n)] = "http." + base + ".followRedirects" + env[fmt.Sprintf("GIT_CONFIG_VALUE_%d", n)] = "false" + n++ + env["GIT_CONFIG_COUNT"] = strconv.Itoa(n) + env["AGENTFLOW_GITLAB_TOKEN"] = token + return nil +} diff --git a/internal/runtime/gitlab_auth_test.go b/internal/runtime/gitlab_auth_test.go new file mode 100644 index 0000000..73fedc3 --- /dev/null +++ b/internal/runtime/gitlab_auth_test.go @@ -0,0 +1,46 @@ +package runtime + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "testing" +) + +func TestGitLabEnvironmentScopesCredential(t *testing.T) { + var aToken, bToken string + a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, p, ok := r.BasicAuth(); ok { + aToken = p + } + w.Header().Set("WWW-Authenticate", `Basic realm="git"`) + w.WriteHeader(401) + })) + defer a.Close() + b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, p, ok := r.BasicAuth(); ok { + bToken = p + } + w.Header().Set("WWW-Authenticate", `Basic realm="git"`) + w.WriteHeader(401) + })) + defer b.Close() + env := map[string]string{"GIT_TERMINAL_PROMPT": "0"} + if err := BindGitLabEnvironment(env, a.URL, "example-test-token"); err != nil { + t.Fatal(err) + } + run := func(url string) { + c := exec.Command("git", "ls-remote", url+"/g/r.git") + c.Env = os.Environ() + for k, v := range env { + c.Env = append(c.Env, k+"="+v) + } + _ = c.Run() + } + run(a.URL) + run(b.URL) + if aToken != "example-test-token" || bToken != "" { + t.Fatal("credential was not scoped to configured GitLab host") + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c14acda..13a8c24 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -28,14 +28,16 @@ type CodeBuddyCredential struct { // RunCredentials lives only on the in-memory run sandbox handle. It is never // serialized into domain objects, run-context.json, or workspace files. type RunCredentials struct { - TaihuPAT string - CodeBuddy CodeBuddyCredential - Codex ProviderCredential - ClaudeCode ProviderCredential - GitToken string - GitUserName string - GitUserEmail string - MCPServers []domain.MCPServerDefinition + TaihuPAT string + CodeBuddy CodeBuddyCredential + Codex ProviderCredential + ClaudeCode ProviderCredential + GitToken string + GitLabToken string + GitLabBaseURL string + GitUserName string + GitUserEmail string + MCPServers []domain.MCPServerDefinition } type ProviderCredential struct { diff --git a/internal/runtime/workspace_preparation.go b/internal/runtime/workspace_preparation.go index 426b25e..1177203 100644 --- a/internal/runtime/workspace_preparation.go +++ b/internal/runtime/workspace_preparation.go @@ -157,7 +157,7 @@ func redactPreparationOutput(sb Sandbox, output string) string { managed.credentialsMu.RLock() credentials := managed.credentials managed.credentialsMu.RUnlock() - secrets = append(secrets, credentials.GitToken, credentials.TaihuPAT, credentials.CodeBuddy.APIKey, credentials.Codex.APIKey, credentials.ClaudeCode.APIKey) + secrets = append(secrets, credentials.GitToken, credentials.GitLabToken, credentials.TaihuPAT, credentials.CodeBuddy.APIKey, credentials.Codex.APIKey, credentials.ClaudeCode.APIKey) for _, server := range credentials.MCPServers { for _, value := range server.Headers { secrets = append(secrets, value) diff --git a/internal/tracker/gitlab.go b/internal/tracker/gitlab.go new file mode 100644 index 0000000..4cfab29 --- /dev/null +++ b/internal/tracker/gitlab.go @@ -0,0 +1,162 @@ +package tracker + +import ( + "agentflow/internal/domain" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type gitLabClient struct { + base, path, token string + http *http.Client +} +type gitLabMR struct { + IID int `json:"iid"` + State string `json:"state"` + WebURL string `json:"web_url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + SHA string `json:"sha"` +} + +func (s *Service) gitLabClient(project string) (*gitLabClient, error) { + if s.projectAuth == nil { + return nil, fmt.Errorf("GitLab 项目配置不可用") + } + for _, r := range s.projectAuth.Repositories() { + if r.Provider != "gitlab" || r.GongfengProjectID != project { + continue + } + p, ok := s.projectAuth.Project(r.ProjectID) + if !ok { + break + } + u, ok := s.projectAuth.User(p.OwnerID) + if !ok || u.GitLabToken == "" { + return nil, fmt.Errorf("项目负责人尚未配置 GitLab Token") + } + info, ok := domain.ParseGitLabRepositoryURL(project, u.GitLabBaseURL) + if !ok || info.Project != project { + return nil, fmt.Errorf("仓库地址与项目负责人的 GitLab 地址不一致") + } + base := strings.TrimRight(u.GitLabBaseURL, "/") + path := strings.TrimPrefix(strings.TrimPrefix(project, "gitlab:"), base+"/") + return &gitLabClient{base: base, path: path, token: u.GitLabToken, http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}, nil + } + return nil, fmt.Errorf("GitLab 仓库未配置") +} +func (g *gitLabClient) request(method, suffix string, input, output any) error { + var body io.Reader + if input != nil { + b, err := json.Marshal(input) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequest(method, g.base+"/api/v4/projects/"+url.PathEscape(g.path)+suffix, body) + if err != nil { + return err + } + req.Header.Set("PRIVATE-TOKEN", g.token) + req.Header.Set("Content-Type", "application/json") + res, err := g.http.Do(req) + if err != nil { + return fmt.Errorf("无法连接 GitLab") + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("GitLab 返回 HTTP %d,请检查权限与仓库状态", res.StatusCode) + } + if output == nil { + _, err = io.Copy(io.Discard, io.LimitReader(res.Body, 4<<20)) + return err + } + return json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(output) +} +func (g *gitLabClient) find(branch, target string) (gitLabMR, bool, error) { + q := url.Values{"source_branch": {branch}, "per_page": {"100"}, "order_by": {"updated_at"}, "sort": {"desc"}} + if target != "" { + q.Set("target_branch", target) + } + var rows []gitLabMR + if err := g.request("GET", "/merge_requests?"+q.Encode(), nil, &rows); err != nil { + return gitLabMR{}, false, err + } + for _, row := range rows { + if row.SourceBranch == branch && (target == "" || row.TargetBranch == target) { + return row, true, nil + } + } + return gitLabMR{}, false, nil +} +func (m gitLabMR) delivery(project string) domain.DeliveryMR { + return domain.DeliveryMR{Project: project, IID: m.IID, URL: m.WebURL, State: m.State, GongfengState: m.State, TargetBranch: m.TargetBranch} +} +func (s *Service) gitLabFindBranch(project, branch string) (domain.DeliveryMR, bool, error) { + g, err := s.gitLabClient(project) + if err != nil { + return domain.DeliveryMR{}, false, err + } + m, ok, err := g.find(branch, "") + return m.delivery(project), ok, err +} +func (s *Service) gitLabMergeBranch(project, branch, target, title, description string) (string, error) { + if strings.TrimSpace(branch) == "" { + return "", fmt.Errorf("source branch is required") + } + if branch == target { + return "", nil + } + g, err := s.gitLabClient(project) + if err != nil { + return "", err + } + m, found, err := g.find(branch, target) + if err != nil { + return "", err + } + if found && m.State == "merged" { + return "!" + strconv.Itoa(m.IID), nil + } + if !found || m.State == "closed" { + if target == "" { + return "", ErrNoOpenMR + } + if title == "" { + title = "LoopForge: " + branch + " → " + target + } + if err := g.request("POST", "/merge_requests", map[string]any{"source_branch": branch, "target_branch": target, "title": title, "description": description}, &m); err != nil { + return "", err + } + } + if strings.TrimSpace(m.SHA) == "" { + return "", fmt.Errorf("GitLab 未返回源分支提交,拒绝合并") + } + var merged gitLabMR + if err := g.request("PUT", "/merge_requests/"+strconv.Itoa(m.IID)+"/merge", map[string]any{"sha": m.SHA, "should_remove_source_branch": false}, &merged); err != nil { + return "", err + } + if merged.State != "merged" { + return "", fmt.Errorf("GitLab 未确认合并 !%d", m.IID) + } + return "!" + strconv.Itoa(m.IID), nil +} +func (s *Service) refreshGitLabDelivery(store MRDeliveryStore, project string, number int) error { + g, err := s.gitLabClient(project) + if err != nil { + return err + } + var m gitLabMR + if err := g.request("GET", "/merge_requests/"+strconv.Itoa(number), nil, &m); err != nil { + return err + } + return store.UpdateMRDeliveryState(project, number, m.State, m.TargetBranch, time.Now().UTC()) +} diff --git a/internal/tracker/gitlab_test.go b/internal/tracker/gitlab_test.go new file mode 100644 index 0000000..787695d --- /dev/null +++ b/internal/tracker/gitlab_test.go @@ -0,0 +1,67 @@ +package tracker + +import ( + "agentflow/internal/domain" + "agentflow/internal/repo" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGitLabMRUsesConfiguredOwnerAndChecksMerge(t *testing.T) { + merged := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("PRIVATE-TOKEN") != "gitlab-secret" { + t.Error("wrong token") + } + if !strings.Contains(r.URL.EscapedPath(), "team%2Fsub%2Frepo") { + t.Errorf("path not encoded: %s", r.URL.EscapedPath()) + } + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case "GET": + w.Write([]byte(`[{"iid":7,"state":"opened","source_branch":"feature","target_branch":"main","sha":"abc","web_url":"http://example.invalid/mr/7"}]`)) + case "PUT": + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + if b["sha"] != "abc" { + t.Error("missing observed sha") + } + state := "opened" + if merged { + state = "merged" + } + json.NewEncoder(w).Encode(map[string]any{"iid": 7, "state": state}) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + st, err := repo.OpenTest() + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.PutUser(domain.User{LoginName: "gitlab-owner", GitLabToken: "gitlab-secret", GitLabBaseURL: srv.URL}); err != nil { + t.Fatal(err) + } + u, _ := st.UserByLoginName("gitlab-owner") + if err := st.PutProject(domain.Project{ID: "p", Name: "p", OwnerID: u.ID}); err != nil { + t.Fatal(err) + } + project := "gitlab:" + srv.URL + "/team/sub/repo" + if err := st.PutRepository(domain.Repository{ID: "r", ProjectID: "p", Provider: "gitlab", Name: "repo", GongfengProjectID: project}); err != nil { + t.Fatal(err) + } + service := NewService() + service.ConfigureWithStore(domain.SyncConfig{}, st) + if _, err := service.EnsureAndMergeBranchMR(project, "feature", "main", "", ""); err == nil { + t.Fatal("must not claim merge without confirmation") + } + merged = true + if ref, err := service.EnsureAndMergeBranchMR(project, "feature", "main", "", ""); err != nil || ref != "!7" { + t.Fatalf("merge %q: %v", ref, err) + } +} diff --git a/internal/tracker/merge_mr.go b/internal/tracker/merge_mr.go index 43447dd..a8b8c8a 100644 --- a/internal/tracker/merge_mr.go +++ b/internal/tracker/merge_mr.go @@ -26,6 +26,9 @@ func (s *Service) MergeSourceBranchMR(project, sourceBranch string) (string, err // only merges an already-open MR for the source (no create). Same source and // target is a no-op. func (s *Service) EnsureAndMergeBranchMR(project, sourceBranch, targetBranch, title, description string) (string, error) { + if strings.HasPrefix(project, "gitlab:") { + return s.gitLabMergeBranch(project, sourceBranch, targetBranch, title, description) + } if strings.HasPrefix(project, "github:") { return s.githubMergeBranch(project, sourceBranch, targetBranch, title, description) } @@ -137,6 +140,9 @@ type gongfengListedMR struct { // newest non-closed one, else the newest closed one. It is the authoritative // MR of a run repository; agents' MR links in artifacts are only a fallback. func (s *Service) FindBranchMR(project, sourceBranch string) (domain.DeliveryMR, bool, error) { + if strings.HasPrefix(project, "gitlab:") { + return s.gitLabFindBranch(project, sourceBranch) + } if strings.HasPrefix(project, "github:") { return s.githubFindBranch(project, sourceBranch) } diff --git a/internal/tracker/mr_delivery_sync.go b/internal/tracker/mr_delivery_sync.go index e4522fe..d386574 100644 --- a/internal/tracker/mr_delivery_sync.go +++ b/internal/tracker/mr_delivery_sync.go @@ -116,6 +116,14 @@ func (s *Service) syncMRDeliveryStates(store MRDeliveryStore) { now := time.Now().UTC() updated := 0 for _, row := range pending { + if strings.HasPrefix(row.MRProject, "gitlab:") { + if err := s.refreshGitLabDelivery(store, row.MRProject, row.MRIID); err != nil { + log.Printf("GitLab delivery refresh: %v", err) + } else { + updated++ + } + continue + } if strings.HasPrefix(row.MRProject, "github:") { if err := s.refreshGitHubDelivery(store, row.MRProject, row.MRIID); err != nil { log.Printf("GitHub delivery refresh: %v", err) @@ -152,7 +160,7 @@ func (s *Service) discoverPromoteMRs(store MRDeliveryStore) { now := time.Now().UTC() for _, repo := range store.Repositories() { project := strings.TrimSpace(repo.GongfengProjectID) - if strings.HasPrefix(project, "github:") { + if strings.HasPrefix(project, "github:") || strings.HasPrefix(project, "gitlab:") { continue } def := strings.TrimSpace(repo.DefaultBranch) @@ -221,7 +229,7 @@ func (s *Service) syncWorkCommitsOnDefaultBranch(store MRDeliveryStore) { } for _, repo := range store.Repositories() { project := strings.TrimSpace(repo.GongfengProjectID) - if strings.HasPrefix(project, "github:") { + if strings.HasPrefix(project, "github:") || strings.HasPrefix(project, "gitlab:") { continue } def := strings.TrimSpace(repo.DefaultBranch) diff --git a/web/app/issues.js b/web/app/issues.js index 37130a1..3971c50 100644 --- a/web/app/issues.js +++ b/web/app/issues.js @@ -420,6 +420,8 @@ function parseGongfengRepoURL(raw){ if(publicURL.startsWith('github.com/'))publicURL='https://'+publicURL; try{const u=new URL(publicURL);if(u.hostname==='github.com'&&!u.username&&!u.password&&['http:','https:'].includes(u.protocol)){const parts=u.pathname.split('/').filter(Boolean),owner=parts[0],name=(parts[1]||'').replace(/\.git$/,'');if(parts.length>=2&&/^[A-Za-z0-9_.-]+$/.test(owner)&&/^[A-Za-z0-9_.-]+$/.test(name)&&!['.','..'].includes(owner)&&!['.','..'].includes(name))return {project:'github:'+owner+'/'+name,name,webUrl:'https://github.com/'+owner+'/'+name,cloneUrl:'https://github.com/'+owner+'/'+name+'.git',provider:'github'}}}catch(_){} + const gitLabBase=(userById(state.currentUser?.id)||{}).gitLabBaseUrl; + if(gitLabBase){try{const base=new URL(gitLabBase);let input=String(raw||'').trim().replace(/^git clone /,'').replace(/^gitlab:/,'');if(input.startsWith('git@')){const m=input.match(/^git@([^:]+):(.+)$/);if(m&&m[1].toLowerCase()===base.hostname.toLowerCase())input=base.origin+'/'+m[2]}const u=new URL(input);if(u.origin.toLowerCase()===base.origin.toLowerCase()&&!u.username&&!u.password&&!u.search&&!u.hash){let path=u.pathname.replace(/^\/+|\/+$/g,'').replace(/\.git$/,'').split('/-/')[0];const parts=path.split('/');if(parts.length>=2&&parts.every(x=>x&&!x.includes('..')&&!/[\s?#\\%]/.test(x))){const webUrl=u.origin+'/'+path;return {provider:'gitlab',project:'gitlab:'+webUrl,name:parts[parts.length-1],webUrl,cloneUrl:webUrl+'.git'}}}}catch(_){}} let original=String(raw||'').trim().replace(/^['"]+|['"]+$/g,''); if(!original)return null; const strippedClone=/^git\s+clone\s+/i.test(original); @@ -462,22 +464,24 @@ function fillRepositoryFromLink(url,fill){ } function renderRepositoryCredentialHint(){ const host=$('#repositoryCredentialHint');if(!host)return; - const configured=!!(userById(state.currentUser?.id)||{}).gitTokenConfigured; - const title=configured?'已配置 GitHub Token':'仓库可以先保存,代码任务还需要 Token'; - const message=configured?'请确认 Token 已获得目标仓库的访问和写入权限。SOP 使用需求责任人的 Token,未指定时使用项目 Owner 的 Token。':'使用仓库进行需求分析、访问私有代码或提交 PR 前,请先配置 GitHub Token。SOP 使用需求责任人的 Token,未指定时使用项目 Owner 的 Token。'; + const current=userById(state.currentUser?.id)||{}; + const configured=!!(current.gitTokenConfigured||current.gitLabTokenConfigured); + const title=configured?lfT('已配置代码仓库 Token','Git provider token configured'):lfT('仓库可以先保存,代码任务还需要 Token','You can save the repository now; code tasks also need a token'); + const message=configured?lfT('请确认 Token 已获得目标仓库的访问和写入权限。SOP 使用需求责任人的 Token,未指定时使用项目 Owner 的 Token。','Confirm that the token can access and write to the target repository. SOPs use the responsible user’s token, falling back to the project owner when no user is assigned.'):lfT('使用仓库进行需求分析、访问私有代码或交付前,请先配置对应平台的 Token。SOP 使用需求责任人的 Token,未指定时使用项目 Owner 的 Token。','Configure the matching provider token before analyzing private code or delivering changes. SOPs use the responsible user’s token, falling back to the project owner when no user is assigned.'); host.dataset.configured=String(configured); - host.innerHTML=`
${escapeHTML(title)}

${escapeHTML(message)}

`; + host.innerHTML=`
${escapeHTML(title)}

${escapeHTML(message)}

`; host.querySelector('[data-repository-configure-token]').onclick=()=>{ $('#repositoryModal').classList.remove('open');switchView('settings'); - const field=document.querySelector('#userCredentialForm [name="gitToken"]'); + const provider=parseGongfengRepoURL(document.querySelector('#repositoryForm [name="repoUrl"]')?.value)?.provider; + const field=document.querySelector(`#userCredentialForm [name="${provider==='gitlab'?'gitLabToken':'gitToken'}"]`); field?.focus();field?.scrollIntoView({block:'center'}); }; } -function renderRepositoryList(){renderRepositoryCredentialHint();const project=(state.data.projects||[]).find(p=>p.id===state.projectFilter),repos=(state.data.repositories||[]).filter(r=>r.projectId===state.projectFilter);$('#repositoryProjectHint').textContent=`当前项目:${project?.name||'—'}。粘贴任意一种 GitHub 地址即可;集成分支用于检查多个 SOP 执行后是否存在合并冲突,可留空。`;$('#repositoryList').innerHTML=repos.map(r=>``).join('')||'

当前项目还没有绑定代码仓库。

';$('#repositoryList').querySelectorAll('[data-edit-repo]').forEach(b=>b.onclick=()=>fillRepositoryForm((state.data.repositories||[]).find(r=>r.id===b.dataset.editRepo)))} +function renderRepositoryList(){renderRepositoryCredentialHint();const project=(state.data.projects||[]).find(p=>p.id===state.projectFilter),repos=(state.data.repositories||[]).filter(r=>r.projectId===state.projectFilter);$('#repositoryProjectHint').textContent=`当前项目:${project?.name||'—'}。粘贴 GitHub 或已配置 GitLab 的地址;集成分支用于检查多个 SOP 执行后是否存在合并冲突,可留空。`;$('#repositoryList').innerHTML=repos.map(r=>``).join('')||'

当前项目还没有绑定代码仓库。

';$('#repositoryList').querySelectorAll('[data-edit-repo]').forEach(b=>b.onclick=()=>fillRepositoryForm((state.data.repositories||[]).find(r=>r.id===b.dataset.editRepo)))} function fillRepositoryForm(repo={}){const f=$('#repositoryForm');['id','name','gongfengProjectId','webUrl','cloneUrl','localPath'].forEach(k=>{if(f.elements[k])f.elements[k].value=repo[k]||''});if(f.elements.repoUrl)f.elements.repoUrl.value=repo.webUrl||repo.cloneUrl||repo.gongfengProjectId||''; const nameField=$('#repositoryNameField');if(nameField)nameField.hidden=!repo.id;f.elements.defaultBranch.value=repo.defaultBranch||'master';f.elements.integrateBranch.value=repo.integrateBranch||'';const project=(state.data.projects||[]).find(p=>p.id===state.projectFilter);f.elements.makeDefault.checked=!project?.defaultRepositoryId||project.defaultRepositoryId===repo.id;if(f.elements.repoUrl)f.elements.repoUrl.onchange=()=>{const form=f.elements,fill={name:form.name.value,gongfengProjectId:form.gongfengProjectId.value,webUrl:form.webUrl.value,cloneUrl:form.cloneUrl.value};if(fillRepositoryFromLink(form.repoUrl.value,fill)){form.name.value=fill.name;form.gongfengProjectId.value=fill.gongfengProjectId;form.webUrl.value=fill.webUrl;form.cloneUrl.value=fill.cloneUrl}}} function openRepositoryModal(){if(!state.projectFilter)return toast('请先选择项目');fillRepositoryForm();renderRepositoryList();$('#repositoryModal').classList.add('open')} -async function saveRepository(e){e.preventDefault();const f=e.currentTarget,project=(state.data.projects||[]).find(p=>p.id===state.projectFilter);if(f.elements.repoUrl){const fill={name:f.elements.name.value,gongfengProjectId:f.elements.gongfengProjectId.value,webUrl:f.elements.webUrl.value,cloneUrl:f.elements.cloneUrl.value};if(fillRepositoryFromLink(f.elements.repoUrl.value,fill)){f.elements.name.value=fill.name;f.elements.gongfengProjectId.value=fill.gongfengProjectId;f.elements.webUrl.value=fill.webUrl;f.elements.cloneUrl.value=fill.cloneUrl}}const payload={id:f.elements.id.value,projectId:state.projectFilter,provider:(parseGongfengRepoURL(f.elements.repoUrl?.value)?.provider||'gongfeng'),name:f.elements.name.value.trim(),gongfengProjectId:f.elements.gongfengProjectId.value.trim(),webUrl:f.elements.webUrl.value.trim(),cloneUrl:f.elements.cloneUrl.value.trim(),defaultBranch:f.elements.defaultBranch.value.trim(),integrateBranch:f.elements.integrateBranch.value.trim(),localPath:f.elements.localPath.value.trim(),repoUrl:f.elements.repoUrl?f.elements.repoUrl.value.trim():''};try{const repo=await api('/api/repositories',{method:payload.id?'PUT':'POST',body:JSON.stringify(payload)}),idx=(state.data.repositories||[]).findIndex(r=>r.id===repo.id);if(idx>=0)state.data.repositories[idx]=repo;else(state.data.repositories||(state.data.repositories=[])).push(repo);if(f.elements.makeDefault.checked&&project){const saved=await api('/api/projects',{method:'PUT',body:JSON.stringify({...project,defaultRepositoryId:repo.id})});if(saved&&saved.id){const pi=(state.data.projects||[]).findIndex(p=>p.id===saved.id);if(pi>=0)state.data.projects[pi]=saved;project.defaultRepositoryId=repo.id}else project.defaultRepositoryId=repo.id}fillRepositoryForm(repo);renderRepositoryList();renderIssues();toast((userById(state.currentUser?.id)||{}).gitTokenConfigured?'Git 仓库绑定已保存':'仓库已保存;配置 GitHub Token 后才能进行代码交付。')}catch(err){toast(err.message)}} +async function saveRepository(e){e.preventDefault();const f=e.currentTarget,project=(state.data.projects||[]).find(p=>p.id===state.projectFilter);if(f.elements.repoUrl){const fill={name:f.elements.name.value,gongfengProjectId:f.elements.gongfengProjectId.value,webUrl:f.elements.webUrl.value,cloneUrl:f.elements.cloneUrl.value};if(fillRepositoryFromLink(f.elements.repoUrl.value,fill)){f.elements.name.value=fill.name;f.elements.gongfengProjectId.value=fill.gongfengProjectId;f.elements.webUrl.value=fill.webUrl;f.elements.cloneUrl.value=fill.cloneUrl}}const payload={id:f.elements.id.value,projectId:state.projectFilter,provider:(parseGongfengRepoURL(f.elements.repoUrl?.value)?.provider||'gongfeng'),name:f.elements.name.value.trim(),gongfengProjectId:f.elements.gongfengProjectId.value.trim(),webUrl:f.elements.webUrl.value.trim(),cloneUrl:f.elements.cloneUrl.value.trim(),defaultBranch:f.elements.defaultBranch.value.trim(),integrateBranch:f.elements.integrateBranch.value.trim(),localPath:f.elements.localPath.value.trim(),repoUrl:f.elements.repoUrl?f.elements.repoUrl.value.trim():''};try{const repo=await api('/api/repositories',{method:payload.id?'PUT':'POST',body:JSON.stringify(payload)}),idx=(state.data.repositories||[]).findIndex(r=>r.id===repo.id);if(idx>=0)state.data.repositories[idx]=repo;else(state.data.repositories||(state.data.repositories=[])).push(repo);if(f.elements.makeDefault.checked&&project){const saved=await api('/api/projects',{method:'PUT',body:JSON.stringify({...project,defaultRepositoryId:repo.id})});if(saved&&saved.id){const pi=(state.data.projects||[]).findIndex(p=>p.id===saved.id);if(pi>=0)state.data.projects[pi]=saved;project.defaultRepositoryId=repo.id}else project.defaultRepositoryId=repo.id}fillRepositoryForm(repo);renderRepositoryList();renderIssues();toast(((userById(state.currentUser?.id)||{}).gitTokenConfigured||(userById(state.currentUser?.id)||{}).gitLabTokenConfigured)?'Git 仓库绑定已保存':'仓库已保存;配置对应平台的 Token 后才能进行代码交付。')}catch(err){toast(err.message)}} function fillImportTAPDWorkflow(){ const select=$('#importTAPDWorkflow'); if(!select)return; diff --git a/web/app/projects.js b/web/app/projects.js index 506906e..c1e7eaa 100644 --- a/web/app/projects.js +++ b/web/app/projects.js @@ -58,7 +58,7 @@ function snapshotRepoRow(r){r.snapshot={name:r.name||'',repoUrl:r.repoUrl||'',de function restoreRepoSnapshot(r){if(!r.snapshot)return;Object.assign(r,r.snapshot);delete r.snapshot;delete r.editing;delete r.editingField} function repoRowWebHref(r){const info=parseGongfengRepoURL(r?.webUrl||r?.repoUrl||r?.cloneUrl||r?.gongfengProjectId||'');return info?.webUrl||''} function repoColValue(r,key){if(key==='name')return repoRowName(r);if(key==='repoUrl')return repoRowWebHref(r)||repoRowLink(r);if(key==='defaultBranch')return r.defaultBranch||'main';return String(r.integrateBranch||'').trim()} -async function persistRepoRow(row,projectId){const url=String(row.repoUrl||row.gongfengProjectId||'').trim();if(!url){toast('请填写GitHub 仓库地址');return null}if(!parseGongfengRepoURL(url)&&!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(url)){toast('Git 仓库链接无法识别,请粘贴 github.com 的 Web / SSH / clone URL');return null}if(!projectId||!(state.data.projects||[]).some(p=>p.id===projectId)){toast('请先保存项目');return null}const payload=repoPayloadFromRow(row,projectId);delete payload.gitToken;if(!payload.id)delete payload.id;const repo=await api('/api/repositories',{method:payload.id?'PUT':'POST',body:JSON.stringify(payload)});const ri=(state.data.repositories||[]).findIndex(x=>x.id===repo.id);if(ri>=0)state.data.repositories[ri]=repo;else(state.data.repositories||(state.data.repositories=[])).push(repo);return repo} +async function persistRepoRow(row,projectId){const url=String(row.repoUrl||row.gongfengProjectId||'').trim();if(!url){toast('请填写GitHub 或已配置的 GitLab 仓库地址');return null}if(!parseGongfengRepoURL(url)&&!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(url)){toast('Git 仓库链接无法识别,请粘贴 GitHub 或已配置的 GitLab 仓库链接');return null}if(!projectId||!(state.data.projects||[]).some(p=>p.id===projectId)){toast('请先保存项目');return null}const payload=repoPayloadFromRow(row,projectId);delete payload.gitToken;if(!payload.id)delete payload.id;const repo=await api('/api/repositories',{method:payload.id?'PUT':'POST',body:JSON.stringify(payload)});const ri=(state.data.repositories||[]).findIndex(x=>x.id===repo.id);if(ri>=0)state.data.repositories[ri]=repo;else(state.data.repositories||(state.data.repositories=[])).push(repo);return repo} function repoAdminRowHTML(r,i){ const cols=[{key:'name',ph:'项目名称'},{key:'repoUrl',ph:'GitHub Web / SSH / clone URL'},{key:'defaultBranch',ph:'主分支'},{key:'integrateBranch',ph:'集成分支(可选)'}]; const editing=!!r.editing; @@ -123,7 +123,7 @@ function renderProjectAdmin(){ const members=project.members.map((m,i)=>{const u=userById(m.userId);return`
${userAvatarHTML(u,48)}成员${userCombobox(`member-${i}`,m.userId,'请选择项目成员','data-m="userId"')}
`}).join(''); const owner=userById(project.ownerId); const ownerRow=`
${userAvatarHTML(owner,48)}Owner${userCombobox('ownerId',project.ownerId,'请选择项目 Owner')}
`; - form.innerHTML=`
${project.id?'编辑项目':'创建项目'}

${escapeHTML(project.name||'新项目')}

项目信息工作空间名称、默认 SOP 与代码仓库
${projectBusinessDomainField(project)}
仓库链接主分支集成分支
${draftRepoRows||'
还没有绑定仓库,用下方「+ 添加仓库」直接添加。
'}
已保存仓库按列展示,也只有这些会出现在上方默认仓库下拉。新增行随「保存项目设置」写库,未绑定默认时自动用第一个新仓库。点项目名称或链接打开仓库页面;点「编辑」可改整行。新增请粘贴 GitHub Web、SSH 或 clone URL,名称会自动识别。集成分支用于检查多个 SOP 执行后工作分支之间是否存在合并冲突;不填则跳过冲突对齐(部分仓库不允许创建 agentflow/ 分支)。
${project.tapdWorkspaceId||project.tapdIterationId?``:''}
项目成员Owner 是项目的默认责任人;其他成员无需设置角色
${ownerRow}${members||'
尚未添加其他成员
'}
${project.id?'':''}
`; + form.innerHTML=`
${project.id?'编辑项目':'创建项目'}

${escapeHTML(project.name||'新项目')}

项目信息工作空间名称、默认 SOP 与代码仓库
${projectBusinessDomainField(project)}
仓库链接主分支集成分支
${draftRepoRows||'
还没有绑定仓库,用下方「+ 添加仓库」直接添加。
'}
已保存仓库按列展示,也只有这些会出现在上方默认仓库下拉。新增行随「保存项目设置」写库,未绑定默认时自动用第一个新仓库。点项目名称或链接打开仓库页面;点「编辑」可改整行。新增请粘贴 GitHub 或已配置 GitLab 的 Web、SSH 或 clone URL,名称会自动识别。集成分支用于检查多个 SOP 执行后工作分支之间是否存在合并冲突;不填则跳过冲突对齐(部分仓库不允许创建 agentflow/ 分支)。
${project.tapdWorkspaceId||project.tapdIterationId?``:''}
项目成员Owner 是项目的默认责任人;其他成员无需设置角色
${ownerRow}${members||'
尚未添加其他成员
'}
${project.id?'':''}
`; const draft=project; // 重渲染前把表单里未保存的文本输入(含密码框)同步进草稿,避免重绘时丢失 const captureProjectForm=()=>{const el=form.elements;['key','name','description','color','workflowId','tapdWorkspaceId','tapdIterationId'].forEach(n=>{if(el[n])draft[n]=el[n].value});draft.ownerId=Number(el.ownerId?.value)||0;if(el.defaultRepositoryId)draft.defaultRepositoryId=el.defaultRepositoryId.value;if(el.businessDomainId)draft.businessDomainId=el.businessDomainId.value;captureRepoRows(form,draft);state.projectDraft=draft}; @@ -147,7 +147,7 @@ function renderProjectAdmin(){ } async function saveProjectAdmin(e){e.preventDefault();const f=e.currentTarget,base=clone(state.projectDraft||(state.data.projects||[]).find(p=>p.id===state.projectFilter)||{});captureRepoRows(f,base);const repoRows=Array.isArray(base.repoRows)?base.repoRows:[],rawDefault=f.elements.defaultRepositoryId.value,ownerId=Number(f.elements.ownerId.value)||0,projectId=f.elements.id.value.trim(),repoKnown=id=>(state.data.repositories||[]).some(r=>r.id===id&&r.projectId===projectId),value={...base,id:projectId,key:f.elements.key.value.trim().toUpperCase(),name:f.elements.name.value.trim(),description:f.elements.description.value,color:f.elements.color.value,ownerId,workflowId:base.workflowId||f.elements.workflowId?.value||'',defaultRepositoryId:(!rawDefault||rawDefault.startsWith('draft:')||!repoKnown(rawDefault))?'':rawDefault,members:(base.members||[]).map(m=>({userId:Number(m.userId)||0,role:'member'})).filter(m=>m.userId!==ownerId),clarification:base.clarification||defaultClarification(),runEval:base.runEval,tapdWorkspaceId:(f.elements.tapdWorkspaceId?.value||'').trim(),tapdIterationId:(f.elements.tapdIterationId?.value||'').trim()};const savedBefore=state.data.projects.find(p=>p.id===value.id),expectedDomainId=savedBefore?.businessDomainId||'',targetDomainId=f.elements.businessDomainId?.value??expectedDomainId;delete value.businessDomainId;delete value.repoRows;delete value.repoDrafts;delete value.gitToken;delete value.codeBuddyApiKey;delete value.codeBuddyInternetEnvironment;if(f.elements.tapdClear?.checked)value.tapdClear=true;if(!value.id||!value.name)return toast('项目标识和名称不能为空');if(!value.ownerId)return toast('请选择项目 Owner');if(value.members.some(m=>!m.userId))return toast('请选择有效的项目成员');if(new Set(value.members.map(m=>m.userId)).size!==value.members.length)return toast('项目成员不能重复'); const safeOpaqueRepoId=s=>/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(s)&&!s.includes('..'); - for(const r of repoRows){const url=String(r.repoUrl||r.gongfengProjectId||r.webUrl||r.cloneUrl||'').trim();if(!url)return toast('每个仓库都需要填写GitHub 仓库地址,可粘贴 Web / SSH / clone URL');if(!parseGongfengRepoURL(url)&&!safeOpaqueRepoId(url))return toast(`仓库「${url}」无法识别为Git 仓库链接,请粘贴 github.com 的链接或填写 group/repository 项目路径`)} + for(const r of repoRows){const url=String(r.repoUrl||r.gongfengProjectId||r.webUrl||r.cloneUrl||'').trim();if(!url)return toast('每个仓库都需要填写GitHub 或已配置的 GitLab 仓库地址,可粘贴 Web / SSH / clone URL');if(!parseGongfengRepoURL(url)&&!safeOpaqueRepoId(url))return toast(`仓库「${url}」无法识别为Git 仓库链接,请粘贴 GitHub 或已配置 GitLab 的链接`)} try{const saved=await api('/api/projects',{method:state.data.projects.some(p=>p.id===value.id)?'PUT':'POST',body:JSON.stringify(value)});const savedProjectId=(saved&&saved.id)||value.id;const i=state.data.projects.findIndex(p=>p.id===saved.id||p.id===value.id);if(i>=0)state.data.projects[i]=saved&&saved.id?saved:value;else state.data.projects.push(saved&&saved.id?saved:value); if(targetDomainId!==expectedDomainId){ try{const assigned=await api('/api/projects/'+encodeURIComponent(savedProjectId)+'/business-domain',{method:'PUT',body:JSON.stringify({businessDomainId:targetDomainId,expectedDomainId})});const pi=state.data.projects.findIndex(p=>p.id===savedProjectId);if(pi>=0)state.data.projects[pi]=assigned;businessState.report=null;businessState.seq++}catch(err){throw new Error('项目设置已保存,业务域保存失败:'+err.message)} diff --git a/web/app/workbench.js b/web/app/workbench.js index c0ee348..43fbfe8 100644 --- a/web/app/workbench.js +++ b/web/app/workbench.js @@ -64,6 +64,13 @@ async function renderPersonalSettings(){ 前往GitHub 申请个人访问令牌 ↗
+
${lfT('代码仓库','Code repositories')}${lfT('GitLab 自建','Self-managed GitLab')}${credentialBadge(storedUser.gitLabTokenConfigured)} +

${lfT('配置公司 GitLab 地址与个人访问令牌。HTTP 地址会明文传输 Token。','Configure your GitLab URL and personal access token. HTTP transmits the token without encryption.')}

+ + + ${credentialClearAction('gitLabToken',storedUser.gitLabTokenConfigured,'GitLab Token')} +
+
CodeBuddy模型与执行${credentialBadge(storedUser.codeBuddyApiKeyConfigured)}

用于调用 CodeBuddy 执行 Agent 任务;网络环境按账号所在环境配置。

@@ -90,6 +97,8 @@ async function renderPersonalSettings(){
保存后对下一次任务生效留空不会覆盖已经配置的密钥。

企业微信账号

关联后,Bot 使用你的平台身份和个人凭据执行指令。请在企业微信向 Bot 发送“绑定账号”开始关联。

正在读取绑定…
`; bindCredentialClearButtons(host.querySelector('#userCredentialForm')); + const gitLabVerifyBtn=host.querySelector('#verifyGitLabToken'); + if(gitLabVerifyBtn)gitLabVerifyBtn.onclick=async()=>{const status=host.querySelector('#gitLabTokenVerifyStatus'); status.textContent='正在校验…'; try{const r=await api(`/api/users/${user.id}/gitlab-token/verify`);status.textContent=r.valid?`✓ ${r.login}${lfT(',身份验证通过;仓库权限尚未验证',': identity verified; repository permissions have not been checked')}`:`✗ ${r.error||'校验失败'}`}catch(err){status.textContent=`✗ ${err.message}`}}; const verifyBtn=host.querySelector('#verifyGitToken'); // 状态文案节点每次重渲染后都会重建,统一现查现用,避免写入游离 DOM。 const setStatus=text=>{const el=$('#gitTokenVerifyStatus');if(el)el.textContent=text}; @@ -117,7 +126,7 @@ async function renderPersonalSettings(){ } host.querySelector('#userCredentialForm').onsubmit=async e=>{ e.preventDefault();const f=e.currentTarget,button=f.querySelector('button[type="submit"]');button.disabled=true; - const payload={gitToken:f.elements.gitToken.value.trim(),gitTokenClear:credentialClearPending(f,'gitToken'),codeBuddyApiKey:f.elements.codeBuddyApiKey.value.trim(),codeBuddyApiKeyClear:credentialClearPending(f,'codeBuddyApiKey'),codeBuddyInternetEnvironment:f.elements.codeBuddyInternetEnvironment.value.trim(),codexApiKey:f.elements.codexApiKey.value.trim(),codexApiKeyClear:credentialClearPending(f,'codexApiKey'),codexBaseUrl:f.elements.codexBaseUrl.value.trim(),claudeCodeApiKey:f.elements.claudeCodeApiKey.value.trim(),claudeCodeApiKeyClear:credentialClearPending(f,'claudeCodeApiKey'),claudeCodeBaseUrl:f.elements.claudeCodeBaseUrl.value.trim()}; + const payload={gitLabBaseUrl:f.elements.gitLabBaseUrl.value.trim(),gitLabToken:f.elements.gitLabToken.value.trim(),gitLabTokenClear:credentialClearPending(f,'gitLabToken'),gitToken:f.elements.gitToken.value.trim(),gitTokenClear:credentialClearPending(f,'gitToken'),codeBuddyApiKey:f.elements.codeBuddyApiKey.value.trim(),codeBuddyApiKeyClear:credentialClearPending(f,'codeBuddyApiKey'),codeBuddyInternetEnvironment:f.elements.codeBuddyInternetEnvironment.value.trim(),codexApiKey:f.elements.codexApiKey.value.trim(),codexApiKeyClear:credentialClearPending(f,'codexApiKey'),codexBaseUrl:f.elements.codexBaseUrl.value.trim(),claudeCodeApiKey:f.elements.claudeCodeApiKey.value.trim(),claudeCodeApiKeyClear:credentialClearPending(f,'claudeCodeApiKey'),claudeCodeBaseUrl:f.elements.claudeCodeBaseUrl.value.trim()}; try{if(payload.gitToken||payload.gitTokenClear)state.gitTokenVerify=null;if(payload.gitToken)state.gitTokenAnnounceVerify=true;const saved=await api(`/api/users/${user.id}/credentials`,{method:'PUT',body:JSON.stringify(payload)}),index=state.data.users.findIndex(u=>Number(u.id)===Number(saved.id));if(index>=0)state.data.users[index]=saved;else state.data.users.push(saved);renderPersonalSettings();window.AgentFlowAssistant?.sync();toast('个人凭据已保存')} catch(err){toast(err.message);button.disabled=false} }; From 0e9c079b0def30e46940977bd6ffa1bc4faf881b Mon Sep 17 00:00:00 2001 From: haoqiancheng Date: Tue, 27 Oct 2026 18:08:29 +0800 Subject: [PATCH 2/2] fix: clarify GitLab repository input and verify binding in browser --- e2e/public/gitlab.spec.mjs | 37 +++++++++++++++++++++++++++++++++++++ internal/httpapi/catalog.go | 6 +++--- web/app/projects.js | 2 +- web/index.html | 2 +- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/e2e/public/gitlab.spec.mjs b/e2e/public/gitlab.spec.mjs index e52f7c8..503e804 100644 --- a/e2e/public/gitlab.spec.mjs +++ b/e2e/public/gitlab.spec.mjs @@ -36,3 +36,40 @@ test('GitLab credentials are independent, redacted and verified as identity only 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); + } +}); diff --git a/internal/httpapi/catalog.go b/internal/httpapi/catalog.go index db6108e..f77e2a7 100644 --- a/internal/httpapi/catalog.go +++ b/internal/httpapi/catalog.go @@ -140,7 +140,7 @@ func (h *catalogHandlers) repositories(c *gin.Context) { if primary != "" { info, ok := parseRepository(primary) if !ok { - c.JSON(400, gin.H{"error": "仓库 URL 无法识别,请提供有效的 GitHub 仓库地址"}) + c.JSON(400, gin.H{"error": "仓库 URL 无法识别,请提供 GitHub 地址或项目 Owner 已配置的 GitLab 实例中的仓库地址"}) return } if body.RepoURL != "" && body.URL != "" { @@ -162,7 +162,7 @@ func (h *catalogHandlers) repositories(c *gin.Context) { if info, ok := parseRepository(v.GongfengProjectID); ok { v.GongfengProjectID = info.Project } else if !strings.HasPrefix(v.GongfengProjectID, "github:") && !domain.IsSafeGongfengProjectID(v.GongfengProjectID) { - c.JSON(400, map[string]string{"error": "仓库链接无法识别:请提供有效的 GitHub 仓库地址"}) + c.JSON(400, map[string]string{"error": "仓库链接无法识别:请提供 GitHub 地址或项目 Owner 已配置的 GitLab 仓库地址"}) return } } @@ -192,7 +192,7 @@ func (h *catalogHandlers) repositories(c *gin.Context) { } } if v.ProjectID == "" || v.Name == "" || v.GongfengProjectID == "" { - c.JSON(400, map[string]string{"error": "请提供 projectId 和 GitHub 仓库地址 repoUrl,名称和仓库标识会自动识别"}) + c.JSON(400, map[string]string{"error": "请提供 projectId 和 GitHub / GitLab 仓库地址 repoUrl,名称和仓库标识会自动识别"}) return } if _, ok := h.repo.Project(v.ProjectID); !ok { diff --git a/web/app/projects.js b/web/app/projects.js index c1e7eaa..2f5d814 100644 --- a/web/app/projects.js +++ b/web/app/projects.js @@ -60,7 +60,7 @@ function repoRowWebHref(r){const info=parseGongfengRepoURL(r?.webUrl||r?.repoUrl function repoColValue(r,key){if(key==='name')return repoRowName(r);if(key==='repoUrl')return repoRowWebHref(r)||repoRowLink(r);if(key==='defaultBranch')return r.defaultBranch||'main';return String(r.integrateBranch||'').trim()} async function persistRepoRow(row,projectId){const url=String(row.repoUrl||row.gongfengProjectId||'').trim();if(!url){toast('请填写GitHub 或已配置的 GitLab 仓库地址');return null}if(!parseGongfengRepoURL(url)&&!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(url)){toast('Git 仓库链接无法识别,请粘贴 GitHub 或已配置的 GitLab 仓库链接');return null}if(!projectId||!(state.data.projects||[]).some(p=>p.id===projectId)){toast('请先保存项目');return null}const payload=repoPayloadFromRow(row,projectId);delete payload.gitToken;if(!payload.id)delete payload.id;const repo=await api('/api/repositories',{method:payload.id?'PUT':'POST',body:JSON.stringify(payload)});const ri=(state.data.repositories||[]).findIndex(x=>x.id===repo.id);if(ri>=0)state.data.repositories[ri]=repo;else(state.data.repositories||(state.data.repositories=[])).push(repo);return repo} function repoAdminRowHTML(r,i){ - const cols=[{key:'name',ph:'项目名称'},{key:'repoUrl',ph:'GitHub Web / SSH / clone URL'},{key:'defaultBranch',ph:'主分支'},{key:'integrateBranch',ph:'集成分支(可选)'}]; + const cols=[{key:'name',ph:'项目名称'},{key:'repoUrl',ph:lfT('GitHub / GitLab 仓库地址','GitHub / GitLab repository URL')},{key:'defaultBranch',ph:'主分支'},{key:'integrateBranch',ph:'集成分支(可选)'}]; const editing=!!r.editing; const href=repoRowWebHref(r); const cells=cols.map(col=>{ diff --git a/web/index.html b/web/index.html index d44a80f..a18381a 100644 --- a/web/index.html +++ b/web/index.html @@ -800,7 +800,7 @@

新建需求

- +