Files
knowledge-governance/internal/platform/postgres/release_batches.go
姚章浩 70131c6161 feat(release): 批量审核 + 建批即锁定 —— 发布管理单文档交互降到 3~4 次
P0-3:
- 批量审核:新增 POST /api/v1/review-candidates/batch-decision,逐条复用单条审核的写入
  路径(同一套合法性校验与"最新验收轮次"绑定),逐条回报 decided/skipped/skipped_ids;
  不可审核的条目只计入 skipped,不打断整批,其他错误整批回滚(不留半成品)。
  界面新增「全选可批准」「批量批准选中」「批量驳回选中」。
- 建批即锁定:建批请求加 seal 字段,服务端在创建后立即执行既有的密封(仍逐项复核门禁,
  失败整批回滚),草稿态不再单独占一步;界面按钮改为「创建并锁定清单」,流程说明从 8 步改 7 步。
- 判据修正:「可批准」原先把已驳回的算进去(review_state !== 'approved'),改为只含 pending + 门禁通过。

语义边界(写进测试):批量批准按人工身份记录,人工可推翻自己先前的驳回;system:auto 发起的
批量批准对人工驳回过的那条必须跳过(Decided=0/Skipped=1)——机器永远不覆盖人工。

测试:review_batch_integration_test.go(批量决定/跳报/重复 id 拒绝/机器不覆盖人工、
建批即锁定)、review_batch_test.go(幂等键、400/409/500/501 映射、服务端盖操作人)、
reviewRelease.test.ts(批量请求体与逐条回报、建批带 seal)。前端 181 条通过。
2026-10-10 15:29:31 +08:00

509 lines
23 KiB
Go

package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"knowledge-governance/internal/governance"
)
const releaseGatePolicyVersion = "release-gate-v1"
// ensureFrozenChangeSetForPlan 把当前实时计划冻结为一份已确认的不可变版本,返回其 id。
// 复用规则:与当前计划摘要完全一致的最近版本(planned 或 confirmed)直接复用并确认
// (对已确认版本 Confirm 是幂等重放),因此同一份计划不会每个批次都堆一版;计划变了才新建。
func (s *Store) ensureFrozenChangeSetForPlan(ctx context.Context, request governance.CreateReleaseBatchRequest) (string, error) {
plan, err := s.ListReleasePlan(ctx)
if err != nil {
return "", err
}
if len(plan.Items) == 0 {
return "", ErrReleaseChangeSetEmpty
}
manifest := ComputeReleaseChangeSetManifest(ChangeSetItemsFromPlan(plan.Items))
existing, err := s.findReusableByManifest(ctx, manifest)
switch {
case err == nil:
confirmed, confirmErr := s.ConfirmReleaseChangeSet(ctx, governance.ConfirmReleaseChangeSetRequest{ChangeSetID: existing.ID, ConfirmedBy: request.CreatedBy, Now: request.Now})
if confirmErr != nil {
return "", confirmErr
}
return confirmed.ID, nil
case !errors.Is(err, pgx.ErrNoRows):
return "", err
}
generated, err := s.GenerateReleaseChangeSet(ctx, governance.GenerateReleaseChangeSetRequest{Name: "批次冻结 · " + request.Name, CreatedBy: request.CreatedBy, Now: request.Now})
if err != nil {
return "", err
}
confirmed, err := s.ConfirmReleaseChangeSet(ctx, governance.ConfirmReleaseChangeSetRequest{ChangeSetID: generated.ID, ConfirmedBy: request.CreatedBy, Now: request.Now})
if err != nil {
return "", err
}
return confirmed.ID, nil
}
var (
ErrInvalidReleaseBatch = errors.New("invalid release batch")
ErrInvalidReleaseBatchFilter = errors.New("invalid release batch filter")
ErrReleaseBatchNotEligible = errors.New("release batch contains ineligible publications")
ErrReleaseBatchGateConflict = errors.New("release batch gate conflict")
ErrReleaseBatchNotFound = errors.New("release batch not found")
ErrReleaseChangeSetUnconfirmed = errors.New("release change set must be confirmed before batching")
)
const releaseGateCTEs = `
acceptance_run_summary AS (
SELECT publication_id, run_id, MAX(observed_at) AS observed_at,
COALESCE(MAX(state) FILTER (WHERE check_type = 'knowledge_exists'), '') AS knowledge_exists,
COALESCE(MAX(state) FILTER (WHERE check_type = 'chunks_present'), '') AS chunks_present,
COALESCE(MAX(state) FILTER (WHERE check_type = 'retrieval_hit'), '') AS retrieval_hit
FROM acceptance_results
GROUP BY publication_id, run_id
), latest_acceptance AS (
SELECT DISTINCT ON (publication_id)
publication_id, run_id, knowledge_exists, chunks_present, retrieval_hit, observed_at
FROM acceptance_run_summary
ORDER BY publication_id, observed_at DESC, run_id DESC
), latest_review AS (
SELECT DISTINCT ON (publication_id)
id, publication_id, decision, decided_visibility, acceptance_run_id,
publication_updated_at, created_at
FROM publication_reviews
ORDER BY publication_id, created_at DESC, id DESC
), release_ready AS (
SELECT publication.id AS publication_id,
publication.source_item_version_id,
publication.updated_at AS publication_updated_at,
review.id AS review_id,
review.acceptance_run_id,
review.decided_visibility
FROM test_publications AS publication
JOIN source_item_versions AS version ON version.id = publication.source_item_version_id
JOIN source_items AS source ON source.id = version.source_item_id
JOIN latest_acceptance AS acceptance ON acceptance.publication_id = publication.id
JOIN publication_reviews AS review ON review.id = (
SELECT latest_review.id FROM latest_review WHERE latest_review.publication_id = publication.id
)
WHERE review.decision = 'approved'
AND review.acceptance_run_id = acceptance.run_id
AND review.publication_updated_at = publication.updated_at
AND publication.ingest_state = 'uploaded'
AND publication.processing_state = 'completed'
AND publication.retrieval_state = 'unpublished'
AND version.revision = source.revision
AND source.source_change NOT IN ('deleted', 'unavailable')
AND acceptance.knowledge_exists = 'passed'
AND acceptance.chunks_present = 'passed'
AND acceptance.retrieval_hit IN ('passed', 'manual_required')
)
`
const releaseDeleteReadyCTEs = `
source_truth AS (
SELECT source.id, source.source_change,
-- 与 release_plan 同一口径:「禁止导入」记在 source_items.policy_state 上,
-- 必须算进期望策略,否则 delete 就绪判定会把被禁止的条目当"仍然保留"→ 永远不
-- delete-ready(创建封批直接 422)。2026-10-10 实测:计划已给出 delete,但建批仍被拒。
CASE WHEN source.policy_state='blocked' THEN 'blocked'
ELSE COALESCE(item_rule.visibility, directory_rule.visibility, source.visibility) END AS desired_policy
FROM source_items AS source
LEFT JOIN source_item_scope_overrides AS item_rule ON item_rule.source_item_id = source.id
LEFT JOIN LATERAL (
SELECT rule.visibility
FROM directory_scope_rules AS rule
WHERE rule.source_connection_id = source.source_connection_id
AND (rule.directory_path = '' OR source.normalized_path = rule.directory_path OR source.normalized_path LIKE rule.directory_path || '/%')
ORDER BY LENGTH(rule.directory_path) DESC, rule.directory_path DESC
LIMIT 1
) AS directory_rule ON TRUE
), latest_production AS (
SELECT DISTINCT ON (version.source_item_id, publication.target_scope)
publication.id AS publication_id, version.source_item_id, publication.target_scope,
execution_item.production_revision,
batch_item.review_id, batch_item.source_item_version_id,
batch_item.acceptance_run_id, batch_item.publication_updated_at,
batch_item.decided_visibility
FROM release_item_executions AS execution_item
JOIN release_executions AS execution ON execution.id = execution_item.release_execution_id
JOIN test_publications AS publication ON publication.id = execution_item.publication_id
JOIN source_item_versions AS version ON version.id = publication.source_item_version_id
JOIN release_batch_items AS batch_item
ON batch_item.release_batch_id = execution.release_batch_id
AND batch_item.publication_id = publication.id
WHERE execution.state IN ('completed', 'rolled_back')
AND execution_item.state IN ('verified', 'rolled_back')
ORDER BY version.source_item_id, publication.target_scope,
execution.completed_at DESC, execution_item.completed_at DESC, execution_item.id DESC
), release_delete_ready AS (
SELECT production.publication_id, production.source_item_version_id,
production.publication_updated_at, production.review_id,
production.acceptance_run_id, production.decided_visibility,
'delete' AS action
FROM latest_production AS production
JOIN source_truth AS truth ON truth.id = production.source_item_id
WHERE production.production_revision <> 'absent'
AND truth.source_change <> 'unavailable'
AND (
truth.source_change = 'deleted'
OR truth.desired_policy = 'blocked'
OR (production.target_scope = 'internal' AND truth.desired_policy NOT IN ('internal', 'both'))
OR (production.target_scope = 'external' AND truth.desired_policy NOT IN ('external', 'both'))
)
), release_upsert_ready AS (
SELECT ready.*
FROM release_ready AS ready
JOIN test_publications AS publication ON publication.id = ready.publication_id
JOIN source_item_versions AS version ON version.id = publication.source_item_version_id
JOIN source_truth AS truth ON truth.id = version.source_item_id
LEFT JOIN latest_production AS production
ON production.source_item_id = version.source_item_id
AND production.target_scope = publication.target_scope
WHERE truth.source_change NOT IN ('deleted', 'unavailable')
AND truth.desired_policy <> 'blocked'
AND publication.target_scope = ready.decided_visibility
AND (
(publication.target_scope = 'internal' AND truth.desired_policy IN ('internal', 'both'))
OR (publication.target_scope = 'external' AND truth.desired_policy IN ('external', 'both'))
)
AND (production.production_revision IS NULL OR production.production_revision = 'absent' OR production.production_revision <> ready.review_id)
)
`
const createReleaseBatchSQL = `
WITH requested AS (
SELECT value AS publication_id, 'upsert' AS action
FROM jsonb_array_elements_text($5::jsonb)
UNION ALL
SELECT value AS publication_id, 'delete' AS action
FROM jsonb_array_elements_text($6::jsonb)
), ` + releaseGateCTEs + `, ` + releaseDeleteReadyCTEs + `,
request_stats AS (
SELECT COUNT(*)::INTEGER AS requested_count,
COUNT(DISTINCT publication_id)::INTEGER AS distinct_count
FROM requested
), binding AS (
-- R1: when a batch cites a release change set, the set must be CONFIRMED
-- and every requested publication must exist in its frozen manifest as a
-- releasable item. Source refreshes can never move this goalpost.
SELECT 1
WHERE $7::text = '' OR EXISTS (
SELECT 1 FROM release_change_sets rcs
WHERE rcs.id = $7 AND rcs.state = 'confirmed'
AND NOT EXISTS (
SELECT 1 FROM requested q
WHERE NOT EXISTS (
SELECT 1 FROM release_change_set_items frozen
WHERE frozen.change_set_id = rcs.id
AND frozen.publication_id = q.publication_id
AND frozen.can_release
)
)
)
), eligible AS (
SELECT ready.publication_id, ready.source_item_version_id, ready.publication_updated_at,
ready.review_id, ready.acceptance_run_id, ready.decided_visibility,
requested.action
FROM release_upsert_ready AS ready
JOIN requested ON requested.publication_id = ready.publication_id AND requested.action = 'upsert'
UNION ALL
SELECT ready.publication_id, ready.source_item_version_id, ready.publication_updated_at,
ready.review_id, ready.acceptance_run_id, ready.decided_visibility,
ready.action
FROM release_delete_ready AS ready
JOIN requested ON requested.publication_id = ready.publication_id AND requested.action = 'delete'
), created_batch AS (
INSERT INTO release_batches (
id, name, state, policy_version, created_by, item_count, created_at, release_change_set_id
)
SELECT $1, $2, 'draft', 'release-gate-v1', $3, request_stats.requested_count, $4, NULLIF($7::text, '')
FROM request_stats
WHERE EXISTS (SELECT 1 FROM binding)
AND request_stats.requested_count BETWEEN 1 AND 100
AND request_stats.requested_count = request_stats.distinct_count
AND request_stats.requested_count = (SELECT COUNT(*) FROM eligible)
RETURNING id, name, state, policy_version, created_by, sealed_by,
item_count, created_at, sealed_at, release_change_set_id
), inserted_items AS (
INSERT INTO release_batch_items (
release_batch_id, publication_id, review_id, source_item_version_id,
acceptance_run_id, publication_updated_at, decided_visibility, action, created_at
)
SELECT created_batch.id, eligible.publication_id, eligible.review_id,
eligible.source_item_version_id, eligible.acceptance_run_id,
eligible.publication_updated_at, eligible.decided_visibility, eligible.action, $4
FROM created_batch CROSS JOIN eligible
RETURNING release_batch_id
)
SELECT id, name, state, policy_version, created_by, sealed_by,
item_count, created_at, sealed_at, COALESCE(release_change_set_id::text,'')
FROM created_batch
WHERE item_count = (SELECT COUNT(*) FROM inserted_items)
`
const sealReleaseBatchSQL = `
WITH ` + releaseGateCTEs + `, ` + releaseDeleteReadyCTEs + `,
item_stats AS (
SELECT batch.id AS batch_id,
COUNT(item.publication_id)::INTEGER AS total_count,
COUNT(*) FILTER (WHERE
(item.action = 'upsert' AND ready.publication_id IS NOT NULL)
OR (item.action = 'delete' AND delete_ready.publication_id IS NOT NULL)
)::INTEGER AS valid_count
FROM release_batches AS batch
LEFT JOIN release_batch_items AS item ON item.release_batch_id = batch.id
LEFT JOIN release_upsert_ready AS ready
ON ready.publication_id = item.publication_id
AND item.action = 'upsert'
AND ready.review_id = item.review_id
AND ready.acceptance_run_id = item.acceptance_run_id
AND ready.publication_updated_at = item.publication_updated_at
AND ready.decided_visibility = item.decided_visibility
LEFT JOIN release_delete_ready AS delete_ready
ON delete_ready.publication_id = item.publication_id
AND item.action = 'delete'
AND delete_ready.review_id = item.review_id
AND delete_ready.acceptance_run_id = item.acceptance_run_id
AND delete_ready.publication_updated_at = item.publication_updated_at
AND delete_ready.decided_visibility = item.decided_visibility
WHERE batch.id = $1 AND batch.state = 'draft'
GROUP BY batch.id
), sealed AS (
UPDATE release_batches AS batch
SET state = 'sealed', sealed_by = $2, sealed_at = $3
FROM item_stats
WHERE batch.id = item_stats.batch_id
AND batch.policy_version = 'release-gate-v1'
AND item_stats.total_count > 0
AND item_stats.total_count = item_stats.valid_count
AND batch.item_count = item_stats.total_count
RETURNING batch.id, batch.name, batch.state, batch.policy_version,
batch.created_by, batch.sealed_by, batch.item_count,
batch.created_at, batch.sealed_at, COALESCE(batch.release_change_set_id::text,'')
), already_sealed AS (
SELECT id, name, state, policy_version, created_by, sealed_by,
item_count, created_at, sealed_at, COALESCE(release_change_set_id::text,'')
FROM release_batches
WHERE id = $1 AND state = 'sealed'
)
SELECT * FROM sealed
UNION ALL
SELECT * FROM already_sealed
LIMIT 1
`
const countReleaseBatchesSQL = `
SELECT COUNT(*)::INTEGER
FROM release_batches
WHERE $1 = '' OR state = $1
`
const listReleaseBatchesSQL = `
SELECT id, name, state, policy_version, created_by, sealed_by,
item_count, created_at, sealed_at, COALESCE(release_change_set_id::text,'')
FROM release_batches
WHERE $1 = '' OR state = $1
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3
`
const getReleaseBatchSQL = `
SELECT id, name, state, policy_version, created_by, sealed_by,
item_count, created_at, sealed_at, COALESCE(release_change_set_id::text,'')
FROM release_batches
WHERE id = $1
`
const listReleaseBatchItemsSQL = `
SELECT item.publication_id, source.title, item.decided_visibility, item.action
FROM release_batch_items AS item
JOIN test_publications AS publication ON publication.id = item.publication_id
JOIN source_item_versions AS version ON version.id = publication.source_item_version_id
JOIN source_items AS source ON source.id = version.source_item_id
WHERE item.release_batch_id = $1
ORDER BY item.publication_id
`
func (s *Store) CreateReleaseBatch(ctx context.Context, request governance.CreateReleaseBatchRequest) (governance.ReleaseBatch, error) {
if !validCreateReleaseBatch(request) {
return governance.ReleaseBatch{}, ErrInvalidReleaseBatch
}
if strings.TrimSpace(request.ReleaseChangeSetID) == "" {
// P0-1「建批时冻结」:操作员不再需要先"生成新版本 + 确认冻结"。
// 冻结发生在"这一批要发什么"已经确定的时刻——建批这一下;建立之后清单不可变,
// 来源刷新与后续批准都不会改变它(回滚、审计都按它走)。语义与手工冻结完全一致,
// 只是把"按固化键的人"从操作员换成了建批动作本身。
frozenID, err := s.ensureFrozenChangeSetForPlan(ctx, request)
if err != nil {
return governance.ReleaseBatch{}, err
}
request.ReleaseChangeSetID = frozenID
}
batchID, err := newOpaqueID("rb-")
if err != nil {
return governance.ReleaseBatch{}, err
}
publicationIDs, err := json.Marshal(append([]string{}, request.PublicationIDs...))
if err != nil {
return governance.ReleaseBatch{}, ErrInvalidReleaseBatch
}
deletePublicationIDs, err := json.Marshal(append([]string{}, request.DeletePublicationIDs...))
if err != nil {
return governance.ReleaseBatch{}, ErrInvalidReleaseBatch
}
batch, err := scanReleaseBatch(s.db.QueryRow(ctx, createReleaseBatchSQL,
batchID, request.Name, request.CreatedBy, request.Now, publicationIDs, deletePublicationIDs,
strings.TrimSpace(request.ReleaseChangeSetID),
))
if errors.Is(err, pgx.ErrNoRows) {
// R1: distinguish an unconfirmed/non-covering change set from plain
// gate ineligibility so operators get the honest reason.
if request.ReleaseChangeSetID != "" {
var state string
if lookup := s.db.QueryRow(ctx, `SELECT state FROM release_change_sets WHERE id=$1`, request.ReleaseChangeSetID).Scan(&state); lookup != nil || state != "confirmed" {
return governance.ReleaseBatch{}, ErrReleaseChangeSetUnconfirmed
}
}
return governance.ReleaseBatch{}, ErrReleaseBatchNotEligible
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.ReleaseBatch{}, ErrReleaseBatchNotEligible
}
if err != nil {
return governance.ReleaseBatch{}, fmt.Errorf("create release batch: %w", err)
}
if request.SealImmediately {
// P0-3:建批即锁定。清单在冻结时已不可变,草稿态只剩"查看/锁定"两个按钮,
// 等于让操作员多按一次;这里合成一步(锁定仍逐项复核门禁,失败即整批回滚)。
sealed, sealErr := s.SealReleaseBatch(ctx, governance.SealReleaseBatchRequest{BatchID: batch.ID, SealedBy: request.CreatedBy, Now: request.Now})
if sealErr != nil {
return governance.ReleaseBatch{}, sealErr
}
return sealed, nil
}
return batch, nil
}
func (s *Store) SealReleaseBatch(ctx context.Context, request governance.SealReleaseBatchRequest) (governance.ReleaseBatch, error) {
if !validShortID(request.BatchID) || !validReviewerRef(request.SealedBy) || request.Now.IsZero() {
return governance.ReleaseBatch{}, ErrInvalidReleaseBatch
}
batch, err := scanReleaseBatch(s.db.QueryRow(ctx, sealReleaseBatchSQL, request.BatchID, request.SealedBy, request.Now))
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseBatch{}, ErrReleaseBatchGateConflict
}
if err != nil {
return governance.ReleaseBatch{}, fmt.Errorf("seal release batch: %w", err)
}
return batch, nil
}
func (s *Store) ListReleaseBatches(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseBatchPage, error) {
if filter.Limit < 1 || filter.Limit > 100 || filter.Offset < 0 || filter.Offset > 1000000 ||
filter.State != "" && filter.State != string(governance.ReleaseBatchDraft) &&
filter.State != string(governance.ReleaseBatchSealed) && filter.State != string(governance.ReleaseBatchCancelled) {
return governance.ReleaseBatchPage{}, ErrInvalidReleaseBatchFilter
}
page := governance.ReleaseBatchPage{Items: []governance.ReleaseBatch{}, Limit: filter.Limit, Offset: filter.Offset}
if err := s.db.QueryRow(ctx, countReleaseBatchesSQL, filter.State).Scan(&page.Total); err != nil {
return governance.ReleaseBatchPage{}, fmt.Errorf("count release batches: %w", err)
}
rows, err := s.db.Query(ctx, listReleaseBatchesSQL, filter.State, filter.Limit, filter.Offset)
if err != nil {
return governance.ReleaseBatchPage{}, fmt.Errorf("list release batches: %w", err)
}
defer rows.Close()
for rows.Next() {
batch, err := scanReleaseBatch(rows)
if err != nil {
return governance.ReleaseBatchPage{}, fmt.Errorf("scan release batch: %w", err)
}
page.Items = append(page.Items, batch)
}
if err := rows.Err(); err != nil {
return governance.ReleaseBatchPage{}, fmt.Errorf("iterate release batches: %w", err)
}
return page, nil
}
func (s *Store) GetReleaseBatch(ctx context.Context, batchID string) (governance.ReleaseBatchDetail, error) {
if !validShortID(batchID) {
return governance.ReleaseBatchDetail{}, ErrReleaseBatchNotFound
}
batch, err := scanReleaseBatch(s.db.QueryRow(ctx, getReleaseBatchSQL, batchID))
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseBatchDetail{}, ErrReleaseBatchNotFound
}
if err != nil {
return governance.ReleaseBatchDetail{}, fmt.Errorf("get release batch: %w", err)
}
rows, err := s.db.Query(ctx, listReleaseBatchItemsSQL, batchID)
if err != nil {
return governance.ReleaseBatchDetail{}, fmt.Errorf("list release batch items: %w", err)
}
defer rows.Close()
detail := governance.ReleaseBatchDetail{ReleaseBatch: batch, Items: []governance.ReleaseBatchItemView{}}
for rows.Next() {
var item governance.ReleaseBatchItemView
var visibility string
if err := rows.Scan(&item.PublicationID, &item.Title, &visibility, &item.Action); err != nil {
return governance.ReleaseBatchDetail{}, fmt.Errorf("scan release batch item: %w", err)
}
item.DecidedVisibility = governance.Visibility(visibility)
if (item.DecidedVisibility != governance.VisibilityInternal && item.DecidedVisibility != governance.VisibilityExternal) || (item.Action != "upsert" && item.Action != "delete") {
return governance.ReleaseBatchDetail{}, errors.New("invalid release batch item visibility")
}
detail.Items = append(detail.Items, item)
}
if err := rows.Err(); err != nil {
return governance.ReleaseBatchDetail{}, fmt.Errorf("iterate release batch items: %w", err)
}
return detail, nil
}
func validCreateReleaseBatch(request governance.CreateReleaseBatchRequest) bool {
if request.Name == "" || request.Name != strings.TrimSpace(request.Name) || len(request.Name) > 255 ||
strings.ContainsAny(request.Name, "\x00\r\n") || !validReviewerRef(request.CreatedBy) ||
request.Now.IsZero() || len(request.PublicationIDs)+len(request.DeletePublicationIDs) < 1 || len(request.PublicationIDs)+len(request.DeletePublicationIDs) > 100 {
return false
}
if request.ReleaseChangeSetID != "" && !validRouteID(request.ReleaseChangeSetID) {
return false
}
seen := make(map[string]struct{}, len(request.PublicationIDs)+len(request.DeletePublicationIDs))
for _, publicationID := range append(append([]string{}, request.PublicationIDs...), request.DeletePublicationIDs...) {
if !validShortID(publicationID) {
return false
}
if _, exists := seen[publicationID]; exists {
return false
}
seen[publicationID] = struct{}{}
}
return true
}
func scanReleaseBatch(row rowScanner) (governance.ReleaseBatch, error) {
var batch governance.ReleaseBatch
var state string
if err := row.Scan(
&batch.ID, &batch.Name, &state, &batch.PolicyVersion,
&batch.CreatedBy, &batch.SealedBy, &batch.ItemCount,
&batch.CreatedAt, &batch.SealedAt, &batch.ReleaseChangeSetID,
); err != nil {
return governance.ReleaseBatch{}, err
}
batch.State = governance.ReleaseBatchState(state)
if !batch.State.Valid() || batch.PolicyVersion != releaseGatePolicyVersion {
return governance.ReleaseBatch{}, errors.New("invalid release batch row")
}
return batch, nil
}