This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+342
View File
@@ -31,6 +31,16 @@ type Repository interface {
ArchiveProject(ctx context.Context, id uuid.UUID) error
UnarchiveProject(ctx context.Context, id uuid.UUID) error
// ProjectMember 团队/角色 ACL(autonomy roadmap §11)
// UpsertMember 新增或更新一名成员的角色(PK 冲突时更新 role)。
UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error)
// DeleteMember 移除一名成员,返回受影响行数(0 表示不存在)。
DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error)
// GetMemberRole 返回 userID 在 projectID 的成员角色;非成员返回 (RoleNone, nil)。
GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error)
// ListMembers 返回 projectID 的全部成员(按加入时间升序)。
ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error)
// Requirement
CreateRequirement(ctx context.Context, r *Requirement) (*Requirement, error)
GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Requirement, error)
@@ -44,19 +54,48 @@ type Repository interface {
// Issue
CreateIssue(ctx context.Context, i *Issue) (*Issue, error)
GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error)
GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error)
ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error)
ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error)
UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error)
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error)
CloseIssue(ctx context.Context, id uuid.UUID) error
ReopenIssue(ctx context.Context, id uuid.UUID) error
// Issue 依赖图(autonomy roadmap §10)
CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error)
DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error)
// ListBlockers 返回阻塞 blockedID 的 issue 列表(其依赖)。
ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error)
// ListBlocked 返回被 blockerID 阻塞的 issue 列表。
ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error)
// ListBlockerIDs 返回 blockedID 的直接 blocker id(一跳),供 service 层环检查。
ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error)
// ListSubtasks 返回 parentID 的全部子 issue。
ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error)
// ListReadyLeafSubtasks 返回 project 下"就绪可派"的叶子子任务:open、有 parent、
// 全部 blocker 已关闭、无活跃 acp_session、无 open 子。按 priority DESC, number ASC 排序。
ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error)
// ListSchedulableProjects 返回当前至少有一个就绪叶子子任务的 project id(调度器预筛)。
ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error)
// CountActiveSessionsForProject 返回 project 内活跃(starting/running)acp_session 数。
CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error)
// Artifact
CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
// ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error)
GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error)
GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error)
GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error)
// ApproveArtifact 给指定产物落 verdict(pass/fail/none),返回更新后的产物。
ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error)
// PhasePointer:每 (requirement, phase) 的已批准/当前产物指针。
UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID uuid.UUID, approvedBy uuid.UUID) (*PhasePointer, error)
GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error)
ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error)
}
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
@@ -211,6 +250,72 @@ func (r *pgRepo) UnarchiveProject(ctx context.Context, id uuid.UUID) error {
return nil
}
// ===== ProjectMember =====
func rowToMember(row projectsqlc.ProjectMember) *Member {
return &Member{
ProjectID: fromPgUUID(row.ProjectID),
UserID: fromPgUUID(row.UserID),
Role: Role(row.Role),
AddedBy: fromPgUUIDPtr(row.AddedBy),
CreatedAt: row.CreatedAt.Time,
}
}
func (r *pgRepo) UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) {
row, err := r.q.UpsertProjectMember(ctx, projectsqlc.UpsertProjectMemberParams{
ProjectID: toPgUUID(projectID),
UserID: toPgUUID(userID),
Role: string(role),
AddedBy: toPgUUIDPtr(addedBy),
})
if err != nil {
// FK 冲突:user 不存在 → invalid_input(不泄漏 5xx)。
if IsForeignKeyViolation(err) {
return nil, errs.New(errs.CodeInvalidInput, "用户不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "upsert project member")
}
return rowToMember(row), nil
}
func (r *pgRepo) DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) {
n, err := r.q.DeleteProjectMember(ctx, projectsqlc.DeleteProjectMemberParams{
ProjectID: toPgUUID(projectID),
UserID: toPgUUID(userID),
})
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "delete project member")
}
return n, nil
}
func (r *pgRepo) GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) {
row, err := r.q.GetProjectMember(ctx, projectsqlc.GetProjectMemberParams{
ProjectID: toPgUUID(projectID),
UserID: toPgUUID(userID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return RoleNone, nil
}
return RoleNone, errs.Wrap(err, errs.CodeInternal, "get project member role")
}
return Role(row.Role), nil
}
func (r *pgRepo) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) {
rows, err := r.q.ListProjectMembers(ctx, toPgUUID(projectID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list project members")
}
out := make([]*Member, 0, len(rows))
for _, row := range rows {
out = append(out, rowToMember(row))
}
return out, nil
}
// ===== Requirement =====
func rowToRequirement(row projectsqlc.Requirement) *Requirement {
@@ -366,6 +471,8 @@ func rowToIssue(row projectsqlc.Issue) *Issue {
ClosedAt: fromPgTimePtr(row.ClosedAt),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
ParentID: fromPgUUIDPtr(row.ParentID),
Priority: int(row.Priority),
}
}
@@ -379,6 +486,8 @@ func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) {
AssigneeID: toPgUUIDPtr(in.AssigneeID),
CreatedBy: toPgUUID(in.CreatedBy),
WorkspaceID: toPgUUIDPtr(in.WorkspaceID),
ParentID: toPgUUIDPtr(in.ParentID),
Priority: int32(in.Priority), //nolint:gosec // priority 0..3,CHECK 约束兜底
})
if err != nil {
if IsUniqueViolation(err) {
@@ -406,6 +515,17 @@ func (r *pgRepo) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, numb
return rowToIssue(row), nil
}
func (r *pgRepo) GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) {
row, err := r.q.GetIssueByID(ctx, toPgUUID(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "get issue by id")
}
return rowToIssue(row), nil
}
func (r *pgRepo) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) {
var statusPtr, reqFilter *string
if filter.Status != "" {
@@ -499,6 +619,151 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
return nil
}
func (r *pgRepo) UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) {
row, err := r.q.UpdateIssuePriority(ctx, projectsqlc.UpdateIssuePriorityParams{
ID: toPgUUID(id),
Priority: int32(priority), //nolint:gosec // priority 0..3,CHECK 约束兜底
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "update issue priority")
}
return rowToIssue(row), nil
}
// ===== Issue 依赖图(autonomy roadmap §10)=====
func rowToDependency(row projectsqlc.IssueDependency) *Dependency {
return &Dependency{
ID: fromPgUUID(row.ID),
ProjectID: fromPgUUID(row.ProjectID),
BlockedID: fromPgUUID(row.BlockedID),
BlockerID: fromPgUUID(row.BlockerID),
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
}
}
func (r *pgRepo) CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) {
row, err := r.q.CreateDependency(ctx, projectsqlc.CreateDependencyParams{
ID: toPgUUID(d.ID),
ProjectID: toPgUUID(d.ProjectID),
BlockedID: toPgUUID(d.BlockedID),
BlockerID: toPgUUID(d.BlockerID),
CreatedBy: toPgUUID(d.CreatedBy),
})
if err != nil {
if IsUniqueViolation(err) {
return nil, errs.New(errs.CodeInvalidInput, "依赖已存在")
}
if IsForeignKeyViolation(err) {
return nil, errs.Wrap(err, errs.CodeInvalidInput, "依赖两端 issue 不存在或跨 project")
}
return nil, errs.Wrap(err, errs.CodeInternal, "create dependency")
}
return rowToDependency(row), nil
}
func (r *pgRepo) DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) {
n, err := r.q.DeleteDependency(ctx, projectsqlc.DeleteDependencyParams{
ProjectID: toPgUUID(projectID),
BlockedID: toPgUUID(blockedID),
BlockerID: toPgUUID(blockerID),
})
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "delete dependency")
}
return n, nil
}
func (r *pgRepo) ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) {
rows, err := r.q.ListBlockers(ctx, toPgUUID(blockedID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list blockers")
}
out := make([]*Issue, 0, len(rows))
for _, row := range rows {
out = append(out, rowToIssue(row))
}
return out, nil
}
func (r *pgRepo) ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) {
rows, err := r.q.ListBlocked(ctx, toPgUUID(blockerID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list blocked")
}
out := make([]*Issue, 0, len(rows))
for _, row := range rows {
out = append(out, rowToIssue(row))
}
return out, nil
}
func (r *pgRepo) ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) {
rows, err := r.q.ListBlockerIDs(ctx, toPgUUID(blockedID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list blocker ids")
}
out := make([]uuid.UUID, 0, len(rows))
for _, p := range rows {
out = append(out, fromPgUUID(p))
}
return out, nil
}
func (r *pgRepo) ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) {
rows, err := r.q.ListSubtasks(ctx, toPgUUID(parentID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list subtasks")
}
out := make([]*Issue, 0, len(rows))
for _, row := range rows {
out = append(out, rowToIssue(row))
}
return out, nil
}
func (r *pgRepo) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) {
if limit <= 0 {
limit = 50
}
rows, err := r.q.ListReadyLeafSubtasks(ctx, projectsqlc.ListReadyLeafSubtasksParams{
ProjectID: toPgUUID(projectID),
Limit: int32(limit), //nolint:gosec // limit 来自配置/调用方,正向小整数
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list ready leaf subtasks")
}
out := make([]*Issue, 0, len(rows))
for _, row := range rows {
out = append(out, rowToIssue(row))
}
return out, nil
}
func (r *pgRepo) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.q.ListSchedulableProjects(ctx)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list schedulable projects")
}
out := make([]uuid.UUID, 0, len(rows))
for _, p := range rows {
out = append(out, fromPgUUID(p))
}
return out, nil
}
func (r *pgRepo) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) {
n, err := r.q.CountActiveSessionsForProject(ctx, toPgUUID(projectID))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "count active sessions for project")
}
return int(n), nil
}
// ===== Artifact =====
func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
@@ -512,6 +777,17 @@ func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
SourceMessageID: row.SourceMessageID,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
Verdict: Verdict(row.Verdict),
}
}
func rowToPhasePointer(row projectsqlc.RequirementPhasePointer) *PhasePointer {
return &PhasePointer{
RequirementID: fromPgUUID(row.RequirementID),
Phase: Phase(row.Phase),
ApprovedArtifactID: fromPgUUIDPtr(row.ApprovedArtifactID),
ApprovedBy: fromPgUUIDPtr(row.ApprovedBy),
ApprovedAt: fromPgTimePtr(row.ApprovedAt),
}
}
@@ -575,3 +851,69 @@ func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.U
}
return int(max), nil
}
func (r *pgRepo) GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) {
row, err := r.q.GetRequirementArtifactByID(ctx, toPgUUID(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by id")
}
return rowToArtifact(row), nil
}
func (r *pgRepo) ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) {
row, err := r.q.ApproveRequirementArtifact(ctx, projectsqlc.ApproveRequirementArtifactParams{
ID: toPgUUID(artifactID),
Verdict: string(verdict),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "approve artifact")
}
return rowToArtifact(row), nil
}
// ===== PhasePointer =====
func (r *pgRepo) UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) {
row, err := r.q.UpsertRequirementPhasePointer(ctx, projectsqlc.UpsertRequirementPhasePointerParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
ApprovedArtifactID: toPgUUID(artifactID),
ApprovedBy: toPgUUID(approvedBy),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "upsert phase pointer")
}
return rowToPhasePointer(row), nil
}
func (r *pgRepo) GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) {
row, err := r.q.GetRequirementPhasePointer(ctx, projectsqlc.GetRequirementPhasePointerParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "get phase pointer")
}
return rowToPhasePointer(row), nil
}
func (r *pgRepo) ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) {
rows, err := r.q.ListRequirementPhasePointers(ctx, toPgUUID(requirementID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list phase pointers")
}
out := make([]*PhasePointer, 0, len(rows))
for _, row := range rows {
out = append(out, rowToPhasePointer(row))
}
return out, nil
}