Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions internal/prinfo/prinfo.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -19,20 +19,27 @@ const (
// EvidenceID is the identifier for the PR/MR info material type
EvidenceID = "CHAINLOOP_PR_INFO"
// EvidenceSchemaURL is the URL to the JSON schema for PR/MR info
EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json"
EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json"
)

// Reviewer represents a reviewer of the PR/MR
type Reviewer struct {
Login string `json:"login" jsonschema:"required,description=Username of the reviewer"`
Type string `json:"type" jsonschema:"required,enum=User,enum=Bot,enum=unknown,description=Account type of the reviewer"`
}

// Data represents the data payload of the PR/MR info evidence
type Data struct {
Platform string `json:"platform" jsonschema:"required,enum=github,enum=gitlab,description=The CI/CD platform"`
Type string `json:"type" jsonschema:"required,enum=pull_request,enum=merge_request,description=The type of change request"`
Number string `json:"number" jsonschema:"required,description=The PR/MR number or identifier"`
Title string `json:"title" jsonschema:"description=The PR/MR title"`
Description string `json:"description" jsonschema:"description=The PR/MR description or body"`
SourceBranch string `json:"source_branch" jsonschema:"description=The source branch name"`
TargetBranch string `json:"target_branch" jsonschema:"description=The target branch name"`
URL string `json:"url" jsonschema:"required,format=uri,description=Direct URL to the PR/MR"`
Author string `json:"author" jsonschema:"description=Username of the PR/MR author"`
Platform string `json:"platform" jsonschema:"required,enum=github,enum=gitlab,description=The CI/CD platform"`
Type string `json:"type" jsonschema:"required,enum=pull_request,enum=merge_request,description=The type of change request"`
Number string `json:"number" jsonschema:"required,description=The PR/MR number or identifier"`
Title string `json:"title" jsonschema:"description=The PR/MR title"`
Description string `json:"description" jsonschema:"description=The PR/MR description or body"`
SourceBranch string `json:"source_branch" jsonschema:"description=The source branch name"`
TargetBranch string `json:"target_branch" jsonschema:"description=The target branch name"`
URL string `json:"url" jsonschema:"required,format=uri,description=Direct URL to the PR/MR"`
Author string `json:"author" jsonschema:"description=Username of the PR/MR author"`
Reviewers []Reviewer `json:"reviewers,omitempty" jsonschema:"description=List of reviewers who reviewed or were requested to review"`
Comment thread
jiparis marked this conversation as resolved.
}

// Evidence represents the complete evidence structure for PR/MR metadata
Expand Down
99 changes: 98 additions & 1 deletion internal/prinfo/prinfo_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -105,6 +105,57 @@ func TestValidatePRInfo(t *testing.T) {
}`,
wantErr: true,
},
{
name: "valid PR with reviewers",
data: `{
"platform": "github",
"type": "pull_request",
"number": "789",
"url": "https://github.com/owner/repo/pull/789",
"reviewers": [
{"login": "reviewer1", "type": "User"},
{"login": "coderabbitai", "type": "Bot"}
]
}`,
wantErr: false,
},
{
name: "valid PR with empty reviewers",
data: `{
"platform": "github",
"type": "pull_request",
"number": "789",
"url": "https://github.com/owner/repo/pull/789",
"reviewers": []
}`,
wantErr: false,
},
{
name: "invalid reviewer missing login",
data: `{
"platform": "github",
"type": "pull_request",
"number": "789",
"url": "https://github.com/owner/repo/pull/789",
"reviewers": [
{"type": "User"}
]
}`,
wantErr: true,
},
{
name: "invalid reviewer type",
data: `{
"platform": "github",
"type": "pull_request",
"number": "789",
"url": "https://github.com/owner/repo/pull/789",
"reviewers": [
{"login": "reviewer1", "type": "InvalidType"}
]
}`,
wantErr: true,
},
{
name: "invalid JSON",
data: `{invalid json}`,
Expand All @@ -124,6 +175,52 @@ func TestValidatePRInfo(t *testing.T) {
require.NoError(t, err)
}

err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_1)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

func TestValidatePRInfoV1_0BackwardCompat(t *testing.T) {
testCases := []struct {
name string
data string
wantErr bool
}{
{
name: "v1.0 valid PR without reviewers",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"author": "username"
}`,
wantErr: false,
},
{
name: "v1.0 rejects reviewers field",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"reviewers": [{"login": "reviewer1", "type": "User"}]
}`,
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var data interface{}
err := json.Unmarshal([]byte(tc.data), &data)
require.NoError(t, err)

err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_0)
if tc.wantErr {
assert.Error(t, err)
Expand Down
4 changes: 2 additions & 2 deletions internal/prinfo/schemas/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ import (
"github.com/chainloop-dev/chainloop/internal/prinfo"
)

//go:generate go run ./generate.go --output-dir ../../../internal/schemavalidators/internal_schemas/prinfo --version 1.0
//go:generate go run ./generate.go --output-dir ../../../internal/schemavalidators/internal_schemas/prinfo --version 1.1
func main() {
var outputDir string
var version string

flag.StringVar(&outputDir, "output-dir", "../../../internal/schemavalidators/internal_schemas/prinfo", "Directory to output the schema files")
flag.StringVar(&version, "version", "1.0", "Schema version")
flag.StringVar(&version, "version", "1.1", "Schema version")
flag.Parse()

generator := prinfo.NewGenerator()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json",
"properties": {
"platform": {
"type": "string",
"enum": [
"github",
"gitlab"
],
"description": "The CI/CD platform"
},
"type": {
"type": "string",
"enum": [
"pull_request",
"merge_request"
],
"description": "The type of change request"
},
"number": {
"type": "string",
"description": "The PR/MR number or identifier"
},
"title": {
"type": "string",
"description": "The PR/MR title"
},
"description": {
"type": "string",
"description": "The PR/MR description or body"
},
"source_branch": {
"type": "string",
"description": "The source branch name"
},
"target_branch": {
"type": "string",
"description": "The target branch name"
},
"url": {
"type": "string",
"format": "uri",
"description": "Direct URL to the PR/MR"
},
"author": {
"type": "string",
"description": "Username of the PR/MR author"
},
"reviewers": {
"items": {
"properties": {
"login": {
"type": "string",
"description": "Username of the reviewer"
},
"type": {
"type": "string",
"enum": [
"User",
"Bot",
"unknown"
],
"description": "Account type of the reviewer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"login",
"type"
]
},
"type": "array",
"description": "List of reviewers who reviewed or were requested to review"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"platform",
"type",
"number",
"url"
],
"title": "Pull Request / Merge Request Information",
"description": "Schema for Pull Request or Merge Request metadata collected during attestation"
}
8 changes: 7 additions & 1 deletion internal/schemavalidators/schemavalidators.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const (
RunnerContextVersion0_1 RunnerContextVersion = "0.1"
// PRInfoVersion1_0 represents PR/MR Info version 1.0 schema.
PRInfoVersion1_0 PRInfoVersion = "1.0"
// PRInfoVersion1_1 represents PR/MR Info version 1.1 schema (adds reviewers).
PRInfoVersion1_1 PRInfoVersion = "1.1"
// CycloneDXVersion1_5 represents CycloneDX version 1.5 schema.
CycloneDXVersion1_5 CycloneDXVersion = "1.5"
// CycloneDXVersion1_6 represents CycloneDX version 1.6 schema.
Expand Down Expand Up @@ -92,6 +94,8 @@ var (
// PR/MR Info schemas
//go:embed internal_schemas/prinfo/pr-info-1.0.schema.json
prInfoSpecVersion1_0 string
//go:embed internal_schemas/prinfo/pr-info-1.1.schema.json
prInfoSpecVersion1_1 string

// AI Agent Config schemas
//go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json
Expand All @@ -115,6 +119,7 @@ var schemaURLMapping = map[string]string{
"https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0,
"https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1,
"https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0,
"https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json": prInfoSpecVersion1_1,
"https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion0_1,
}

Expand Down Expand Up @@ -143,6 +148,7 @@ func init() {

compiledPRInfoSchemas = make(map[PRInfoVersion]*jsonschema.Schema)
compiledPRInfoSchemas[PRInfoVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json")
compiledPRInfoSchemas[PRInfoVersion1_1] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json")

compiledAIAgentConfigSchemas = make(map[AIAgentConfigVersion]*jsonschema.Schema)
compiledAIAgentConfigSchemas[AIAgentConfigVersion0_1] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json")
Expand Down Expand Up @@ -240,7 +246,7 @@ func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersi
// ValidatePRInfo validates the PR/MR info schema.
func ValidatePRInfo(data interface{}, version PRInfoVersion) error {
if version == "" {
version = PRInfoVersion1_0
version = PRInfoVersion1_1
}

schema, ok := compiledPRInfoSchemas[version]
Expand Down
3 changes: 2 additions & 1 deletion pkg/attestation/crafter/collector_prmetadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func NewPRMetadataCollector(runner SupportedRunner) *PRMetadataCollector {
func (c *PRMetadataCollector) ID() string { return "pr-metadata" }

func (c *PRMetadataCollector) Collect(ctx context.Context, cr *Crafter, attestationID string, casBackend *casclient.CASBackend) error {
isPR, metadata, err := DetectPRContext(c.runner)
isPR, metadata, err := DetectPRContext(ctx, c.runner)
if err != nil {
return fmt.Errorf("detecting PR/MR context: %w", err)
}
Expand All @@ -61,6 +61,7 @@ func (c *PRMetadataCollector) Collect(ctx context.Context, cr *Crafter, attestat
TargetBranch: metadata.TargetBranch,
URL: metadata.URL,
Author: metadata.Author,
Reviewers: metadata.Reviewers,
})

jsonData, err := json.Marshal(evidenceData)
Expand Down
2 changes: 1 addition & 1 deletion pkg/attestation/crafter/materials/chainloop_pr_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (i *ChainloopPRInfoCrafter) Craft(ctx context.Context, artifactPath string)
}

// Validate the data against JSON schema
if err := schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_0); err != nil {
if err := schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_1); err != nil {
i.logger.Debug().Err(err).Msg("schema validation failed")
return nil, fmt.Errorf("PR info validation failed: %w", err)
}
Expand Down
25 changes: 23 additions & 2 deletions pkg/attestation/crafter/materials/chainloop_pr_info_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -118,7 +118,7 @@ func TestChainloopPRInfoCrafter_Validation(t *testing.T) {
require.NoError(t, err)

// Validate the data against JSON schema
err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_0)
err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_1)

if tc.wantErr {
require.Error(t, err)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Expand All @@ -129,6 +129,27 @@ func TestChainloopPRInfoCrafter_Validation(t *testing.T) {
}
}

func TestChainloopPRInfoCrafter_V1_0BackwardCompat(t *testing.T) {
// Data without reviewers should still validate against v1.0
data := prinfo.Data{
Platform: "github",
Type: "pull_request",
Number: "123",
URL: "https://github.com/org/repo/pull/123",
Author: "testuser",
}

dataBytes, err := json.Marshal(data)
require.NoError(t, err)

var rawData interface{}
err = json.Unmarshal(dataBytes, &rawData)
require.NoError(t, err)

err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_0)
require.NoError(t, err)
}

func TestChainloopPRInfoCrafter_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "invalid.json")
Expand Down
Loading
Loading