feat(agnet): allow users to bind git sources

This commit is contained in:
gongzhiyong
2026-05-01 20:55:58 +08:00
parent 75bc93b47a
commit 63fe529b36
9 changed files with 603 additions and 3 deletions
+218
View File
@@ -0,0 +1,218 @@
package controller
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type gitSourcePayload struct {
Name string `json:"name"`
Provider string `json:"provider"`
RepoURL string `json:"repo_url"`
Ref string `json:"ref"`
Paths []string `json:"paths"`
Usage string `json:"usage"`
TenantId string `json:"tenant_id"`
Status string `json:"status"`
}
type gitSourceResponse struct {
Id int `json:"id"`
UserId int `json:"user_id"`
TenantId string `json:"tenant_id"`
Name string `json:"name"`
Provider string `json:"provider"`
RepoURL string `json:"repo_url"`
Ref string `json:"ref"`
Paths []string `json:"paths"`
Usage string `json:"usage"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func normalizeGitSourcePayload(p gitSourcePayload) (gitSourcePayload, error) {
p.Name = strings.TrimSpace(p.Name)
p.Provider = strings.TrimSpace(p.Provider)
p.RepoURL = strings.TrimSpace(p.RepoURL)
p.Ref = strings.TrimSpace(p.Ref)
p.Usage = strings.TrimSpace(p.Usage)
p.TenantId = strings.TrimSpace(p.TenantId)
p.Status = strings.TrimSpace(p.Status)
if p.Name == "" {
return p, errors.New("name required")
}
if p.RepoURL == "" {
return p, errors.New("repo_url required")
}
u, err := url.Parse(p.RepoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return p, errors.New("repo_url must be a valid http(s) URL")
}
if p.Ref == "" {
p.Ref = "main"
}
if p.Provider == "" {
p.Provider = "custom"
}
if p.Usage == "" {
p.Usage = "project"
}
if p.Status == "" {
p.Status = "active"
}
paths := make([]string, 0, len(p.Paths))
for _, path := range p.Paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
if strings.HasPrefix(path, "/") || strings.Contains(path, "..") {
return p, errors.New("paths must be relative and must not contain ..")
}
paths = append(paths, path)
}
if len(paths) == 0 {
paths = []string{"."}
}
p.Paths = paths
return p, nil
}
func gitSourceToResponse(src model.GitSource) gitSourceResponse {
var paths []string
if src.Paths != "" {
_ = common.UnmarshalJsonStr(src.Paths, &paths)
}
if len(paths) == 0 {
paths = []string{"."}
}
return gitSourceResponse{
Id: src.Id,
UserId: src.UserId,
TenantId: src.TenantId,
Name: src.Name,
Provider: src.Provider,
RepoURL: src.RepoURL,
Ref: src.Ref,
Paths: paths,
Usage: src.Usage,
Status: src.Status,
CreatedAt: src.CreatedAt,
UpdatedAt: src.UpdatedAt,
}
}
func ListGitSources(c *gin.Context) {
userId := c.GetInt("id")
var sources []model.GitSource
if err := model.DB.Where("user_id = ?", userId).Order("id desc").Find(&sources).Error; err != nil {
common.ApiError(c, err)
return
}
items := make([]gitSourceResponse, 0, len(sources))
for _, src := range sources {
items = append(items, gitSourceToResponse(src))
}
common.ApiSuccess(c, gin.H{"items": items})
}
func CreateGitSource(c *gin.Context) {
userId := c.GetInt("id")
var payload gitSourcePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
payload, err := normalizeGitSourcePayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
paths, err := common.Marshal(payload.Paths)
if err != nil {
common.ApiError(c, err)
return
}
src := model.GitSource{
UserId: userId,
TenantId: payload.TenantId,
Name: payload.Name,
Provider: payload.Provider,
RepoURL: payload.RepoURL,
Ref: payload.Ref,
Paths: string(paths),
Usage: payload.Usage,
Status: payload.Status,
}
if err := model.DB.Create(&src).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gitSourceToResponse(src))
}
func UpdateGitSource(c *gin.Context) {
userId := c.GetInt("id")
var src model.GitSource
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&src).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "git source not found")
return
}
common.ApiError(c, err)
return
}
var payload gitSourcePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
payload, err := normalizeGitSourcePayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
paths, err := common.Marshal(payload.Paths)
if err != nil {
common.ApiError(c, err)
return
}
src.TenantId = payload.TenantId
src.Name = payload.Name
src.Provider = payload.Provider
src.RepoURL = payload.RepoURL
src.Ref = payload.Ref
src.Paths = string(paths)
src.Usage = payload.Usage
src.Status = payload.Status
if err := model.DB.Save(&src).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gitSourceToResponse(src))
}
func DeleteGitSource(c *gin.Context) {
userId := c.GetInt("id")
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.GitSource{})
if res.Error != nil {
common.ApiError(c, res.Error)
return
}
if res.RowsAffected == 0 {
common.ApiErrorMsg(c, "git source not found")
return
}
common.ApiSuccess(c, gin.H{"deleted": true})
}
+16
View File
@@ -0,0 +1,16 @@
package model
type GitSource struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index;not null"`
TenantId string `json:"tenant_id" gorm:"type:varchar(64);index"`
Name string `json:"name" gorm:"type:varchar(128);not null"`
Provider string `json:"provider" gorm:"type:varchar(32);default:'custom'"`
RepoURL string `json:"repo_url" gorm:"type:varchar(512);not null"`
Ref string `json:"ref" gorm:"type:varchar(128);default:'main'"`
Paths string `json:"paths" gorm:"type:text"`
Usage string `json:"usage" gorm:"type:varchar(32);default:'project'"`
Status string `json:"status" gorm:"type:varchar(32);default:'active'"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
}
+2
View File
@@ -280,6 +280,7 @@ func migrateDB() error {
&SubscriptionPreConsumeRecord{},
&CustomOAuthProvider{},
&UserOAuthBinding{},
&GitSource{},
)
if err != nil {
return err
@@ -328,6 +329,7 @@ func migrateDBFast() error {
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&GitSource{}, "GitSource"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
+10
View File
@@ -174,6 +174,16 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
gitSourceRoute := apiRouter.Group("/git-sources")
gitSourceRoute.Use(middleware.UserAuth())
{
gitSourceRoute.GET("/", controller.ListGitSources)
gitSourceRoute.POST("/", controller.CreateGitSource)
gitSourceRoute.PUT("/:id", controller.UpdateGitSource)
gitSourceRoute.DELETE("/:id", controller.DeleteGitSource)
}
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth())
{
+1
View File
@@ -94,6 +94,7 @@
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/router-plugin": "^1.167.23",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/hast": "^3.0.4",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+71 -1
View File
@@ -96,6 +96,33 @@ export type AgnetDeployment = {
type ApiEnvelope<T> = { success: boolean; data?: T; message?: string }
export type GitSourceUsage = 'project' | 'sk' | 'combined'
export type GitSource = {
id: number
user_id: number
tenant_id?: string
name: string
provider: string
repo_url: string
ref: string
paths: string[]
usage: GitSourceUsage | string
status: string
created_at?: number
updated_at?: number
}
export type GitSourcePayload = {
tenant_id?: string
name: string
provider: string
repo_url: string
ref: string
paths: string[]
usage: GitSourceUsage
}
export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments'
@@ -103,6 +130,18 @@ export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
return res.data?.data?.items ?? []
}
export async function listAgnetDeploymentsQuiet(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments',
{
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>
)
if (!res.data?.success) return []
return res.data?.data?.items ?? []
}
export async function createAgnetDeployment(
body: AgnetCreateDeploymentBody
): Promise<AgnetCreateDeploymentResult> {
@@ -137,7 +176,38 @@ export async function getAgnetAuditLogs() {
export async function getAgnetSnapshots(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
`/api/agnet/deployments/${deploymentId}/sk-snapshots`
`/api/agnet/deployments/${deploymentId}/sk-snapshots`,
{
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>
)
if (!res.data?.success) return []
return res.data?.data?.items ?? []
}
export async function listGitSources(): Promise<GitSource[]> {
const res = await api.get<ApiEnvelope<{ items?: GitSource[] }>>(
'/api/git-sources/'
)
return res.data?.data?.items ?? []
}
export async function createGitSource(
body: GitSourcePayload
): Promise<GitSource> {
const res = await api.post<ApiEnvelope<GitSource>>('/api/git-sources/', body)
if (!res.data?.success || !res.data.data) {
throw new Error(res.data?.message || 'Create Git source failed')
}
return res.data.data
}
export async function deleteGitSource(id: number): Promise<void> {
const res = await api.delete<ApiEnvelope<{ deleted?: boolean }>>(
`/api/git-sources/${id}`
)
if (!res.data?.success) {
throw new Error(res.data?.message || 'Delete Git source failed')
}
}
+261 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
Activity,
@@ -22,6 +22,7 @@ import {
Search,
ShieldCheck,
Tag,
Trash2,
User2,
XCircle,
} from 'lucide-react'
@@ -41,10 +42,16 @@ import {
getAgnetAuditLogs,
getAgnetDeploymentEvents,
getAgnetSnapshots,
createGitSource,
deleteGitSource,
listGitSources,
listAgnetDeployments,
listAgnetDeploymentsQuiet,
type AgnetDeployment,
type AgnetRuntimeExecution,
type AgnetSKAccessPolicy,
type GitSourcePayload,
type GitSourceUsage,
} from './api'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
@@ -642,9 +649,58 @@ export function AgnetAuditPage() {
export function AgnetSKSourcesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [gitForm, setGitForm] = useState<GitSourcePayload>({
name: '',
provider: 'github',
repo_url: '',
ref: 'main',
paths: ['.'],
usage: 'project',
tenant_id: '',
})
const [pathsText, setPathsText] = useState('.')
const gitSourcesQuery = useQuery({
queryKey: ['git-sources'],
queryFn: listGitSources,
})
const gitSources = gitSourcesQuery.data ?? []
const createGitMutation = useMutation({
mutationFn: () =>
createGitSource({
...gitForm,
paths: pathsText
.split('\n')
.map((x) => x.trim())
.filter(Boolean),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['git-sources'] })
setGitForm({
name: '',
provider: 'github',
repo_url: '',
ref: 'main',
paths: ['.'],
usage: 'project',
tenant_id: '',
})
setPathsText('.')
},
})
const deleteGitMutation = useMutation({
mutationFn: deleteGitSource,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['git-sources'] })
},
})
const deploymentsQuery = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
queryFn: listAgnetDeploymentsQuiet,
})
const deployments = deploymentsQuery.data ?? []
const [activeDeployment, setActiveDeployment] = useState<string | undefined>(
@@ -684,6 +740,209 @@ export function AgnetSKSourcesPage() {
</Select>
}
>
<div className='grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.8fr)]'>
<div className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-sm font-medium text-foreground'>
{t('Bind Git source')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
{t('Bind Git source description')}
</p>
</div>
<GitBranch className='h-5 w-5 text-primary' />
</div>
<div className='mt-4 grid gap-3'>
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Source name')}
</label>
<Input
value={gitForm.name}
onChange={(e) =>
setGitForm((v) => ({ ...v, name: e.target.value }))
}
placeholder='project-main'
className='mt-1 h-9 text-xs'
/>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Provider')}
</label>
<Select
value={gitForm.provider}
onValueChange={(provider) =>
setGitForm((v) => ({ ...v, provider }))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='github'>GitHub</SelectItem>
<SelectItem value='gitlab'>GitLab</SelectItem>
<SelectItem value='gitea'>Gitea</SelectItem>
<SelectItem value='gitee'>Gitee</SelectItem>
<SelectItem value='custom'>Custom Git</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
repo_url
</label>
<Input
value={gitForm.repo_url}
onChange={(e) =>
setGitForm((v) => ({ ...v, repo_url: e.target.value }))
}
placeholder='https://github.com/org/repo.git'
className='mt-1 h-9 font-mono text-xs'
/>
</div>
<div className='grid gap-2 sm:grid-cols-3'>
<div>
<label className='text-xs font-medium text-muted-foreground'>
ref
</label>
<Input
value={gitForm.ref}
onChange={(e) =>
setGitForm((v) => ({ ...v, ref: e.target.value }))
}
className='mt-1 h-9 font-mono text-xs'
/>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Usage')}
</label>
<Select
value={gitForm.usage}
onValueChange={(usage) =>
setGitForm((v) => ({
...v,
usage: usage as GitSourceUsage,
}))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='project'>{t('Project repository')}</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='combined'>{t('Combined repository')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
tenant_id
</label>
<Input
value={gitForm.tenant_id}
onChange={(e) =>
setGitForm((v) => ({ ...v, tenant_id: e.target.value }))
}
placeholder='optional'
className='mt-1 h-9 font-mono text-xs'
/>
</div>
</div>
<div>
<label className='text-xs font-medium text-muted-foreground'>
{t('Allowed paths')}
</label>
<textarea
value={pathsText}
onChange={(e) => setPathsText(e.target.value)}
rows={3}
spellCheck={false}
className='mt-1 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring'
/>
</div>
<Button
type='button'
className='w-fit gap-1.5'
disabled={
createGitMutation.isPending ||
!gitForm.name.trim() ||
!gitForm.repo_url.trim()
}
onClick={() => createGitMutation.mutate()}
>
<Plus className='h-3.5 w-3.5' />
{t('Bind source')}
</Button>
</div>
</div>
<div className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4'>
<p className='text-sm font-medium text-foreground'>
{t('Bound Git sources')}
</p>
{gitSourcesQuery.isLoading ? (
<div className='mt-3 space-y-2'>
<Skeleton className='h-16 rounded-xl' />
<Skeleton className='h-16 rounded-xl' />
</div>
) : gitSources.length === 0 ? (
<p className='mt-3 text-xs text-muted-foreground'>
{t('No Git sources bound yet')}
</p>
) : (
<ul className='mt-3 space-y-2'>
{gitSources.map((src) => (
<li
key={src.id}
className='rounded-xl border border-border bg-background/60 p-3'
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{src.name}
</p>
<p className='mt-1 truncate font-mono text-[11px] text-muted-foreground'>
{src.repo_url}
</p>
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
disabled={deleteGitMutation.isPending}
onClick={() => deleteGitMutation.mutate(src.id)}
>
<Trash2 className='h-3.5 w-3.5 text-destructive' />
</Button>
</div>
<div className='mt-2 flex flex-wrap gap-1.5'>
<MetaPill icon={GitBranch} label='ref' value={src.ref} />
<MetaPill icon={Tag} label='usage' value={src.usage} />
<MetaPill
icon={FileSearch}
label='paths'
value={src.paths.join(', ')}
/>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<div className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_50%,transparent)] p-4 text-sm leading-relaxed text-muted-foreground'>
<p className='font-medium text-foreground'>{t('Git binding')}</p>
<p className='mt-2'>{t('Git sources binding explainer')}</p>
+12
View File
@@ -1638,6 +1638,12 @@
"Generic cache": "Generic cache",
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
"Get Started": "Get Started",
"Allowed paths": "Allowed paths",
"Bind Git source": "Bind Git source",
"Bind Git source description": "Manually register a project repo, SK repo, or combined repo. Deployments will choose refs, paths, and AGENT.md from these sources.",
"Bind source": "Bind source",
"Bound Git sources": "Bound Git sources",
"Combined repository": "Project + SK combined repository",
"Git binding": "Git binding",
"Git sources": "Git sources",
"Git sources binding explainer": "Skill (SK) definitions live in Git. Manager does not edit Markdown here—register code repos, SK tool repos, and refs via the Heicode client or your deployment plan sk_sources. Pass runtime and SK-policy parameters into Agnet when it starts each deployment; effective permissions are stored and enforced on Agnet. Below lists immutable snapshot anchors (Git commit / upload artifact) Agnet resolved for auditing.",
@@ -1651,6 +1657,12 @@
"Git-backed SK sources": "Git-backed SK sources",
"Git-backed SK sources description": "Bind repos and capacity first; immutable snapshots after each deployment show which SK lineage actually ran under tenant policy.",
"GitHub": "GitHub",
"No Git sources bound yet": "No Git sources bound yet.",
"Project repository": "Project repository",
"Provider": "Provider",
"SK repository": "SK repository",
"Source name": "Source name",
"Usage": "Usage",
"Give the group a recognizable name and optional description.": "Give the group a recognizable name and optional description.",
"Give this group a recognizable name.": "Give this group a recognizable name.",
"Global configuration and administrative tools.": "Global configuration and administrative tools.",
+12
View File
@@ -1638,6 +1638,12 @@
"Generic cache": "通用缓存",
"Get notified when balance falls below this value": "当余额低于此值时接收通知",
"Get Started": "开始使用",
"Allowed paths": "允许路径",
"Bind Git source": "绑定 Git 来源",
"Bind Git source description": "手工登记项目仓库、SK 仓库或二合一仓库。部署子 Agent 时会从这些来源选择 ref、路径与 AGENT.md。",
"Bind source": "绑定来源",
"Bound Git sources": "已绑定 Git 来源",
"Combined repository": "项目 + SK 二合一仓库",
"Git binding": "Git 绑定",
"Git sources": "Git 来源",
"Git sources binding explainer": "Skill(SK)定义以 Git 为唯一事实源。Manager 不在此编辑 Markdown:请在 Heicode 客户端或部署计划的 sk_sources 中登记仓库与引用;运行时与 SK 策略等参数在 Agnet 拉起编队/子 Agent 时传入。有效权限与策略落账在 Agnet 侧并由其执行;Manager 仅展示 Agnet 回传的不可变快照锚点(Git commit / 上传制品)供审计。",
@@ -1651,6 +1657,12 @@
"Git-backed SK sources": "基于 Git 的 SK 来源",
"Git-backed SK sources description": "先完成仓库与云上能力绑定;每次部署后的不可变快照反映在该租户策略下实际运行的 SK 血缘。",
"GitHub": "GitHub",
"No Git sources bound yet": "还没有绑定 Git 来源。",
"Project repository": "项目仓库",
"Provider": "服务商",
"SK repository": "SK 技能仓库",
"Source name": "来源名称",
"Usage": "用途",
"Give the group a recognizable name and optional description.": "为该分组提供一个可识别的名称和可选的描述。",
"Give this group a recognizable name.": "为此分组提供一个可识别的名称。",
"Global configuration and administrative tools.": "全局配置和管理工具。",