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
60 changes: 58 additions & 2 deletions internal/prinfo/prinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,31 @@

package prinfo

import (
"encoding/json"
"fmt"
)

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.2/pr-info.schema.json"
EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json"

// AuthorTypeUser represents a human user account
AuthorTypeUser = "User"
// AuthorTypeBot represents a bot/service account
AuthorTypeBot = "Bot"
// AuthorTypeUnknown represents an account with unknown type
AuthorTypeUnknown = "unknown"
)

// Author represents the author of the PR/MR with account type information
type Author struct {
Login string `json:"login" jsonschema:"required,description=Username of the PR/MR author"`
Type string `json:"type" jsonschema:"required,enum=User,enum=Bot,enum=unknown,description=Account type of the PR/MR author"`
}

// Reviewer represents a reviewer of the PR/MR
type Reviewer struct {
Login string `json:"login" jsonschema:"required,description=Username of the reviewer"`
Expand All @@ -40,10 +58,48 @@ type Data struct {
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"`
Author *Author `json:"author,omitempty" jsonschema:"description=The PR/MR author"`
Reviewers []Reviewer `json:"reviewers,omitempty" jsonschema:"description=List of reviewers who reviewed or were requested to review"`
}

// UnmarshalJSON implements custom JSON unmarshaling for Data to handle
// backwards compatibility where author can be either a string (v1.0-v1.2)
// or an object (v1.3+).
func (d *Data) UnmarshalJSON(b []byte) error {
// Use an alias to avoid infinite recursion
type Alias Data
aux := &struct {
Author json.RawMessage `json:"author,omitempty"`
*Alias
}{
Alias: (*Alias)(d),
}

if err := json.Unmarshal(b, aux); err != nil {
return err
}

if len(aux.Author) == 0 || string(aux.Author) == "null" {
return nil
}

// Try object format first
var author Author
if err := json.Unmarshal(aux.Author, &author); err == nil {
d.Author = &author
return nil
}

// Fall back to string format
var login string
if err := json.Unmarshal(aux.Author, &login); err == nil {
d.Author = &Author{Login: login, Type: AuthorTypeUnknown}
return nil
}

return fmt.Errorf("author field must be a string or an object with login and type fields")
}

// Evidence represents the complete evidence structure for PR/MR metadata
type Evidence struct {
ID string `json:"chainloop.material.evidence.id"`
Expand Down
130 changes: 130 additions & 0 deletions internal/prinfo/prinfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,133 @@ func TestValidatePRInfoV1_0BackwardCompat(t *testing.T) {
})
}
}

func TestDataUnmarshalJSON(t *testing.T) {
testCases := []struct {
name string
input string
wantAuthor *Author
wantErr bool
}{
{
name: "string author (v1.0-v1.2 backwards compat)",
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":"dependabot[bot]"}`,
wantAuthor: &Author{Login: "dependabot[bot]", Type: "unknown"},
},
{
name: "object author (v1.3)",
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":{"login":"dependabot[bot]","type":"Bot"}}`,
wantAuthor: &Author{Login: "dependabot[bot]", Type: "Bot"},
},
{
name: "no author field",
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1"}`,
wantAuthor: nil,
},
{
name: "null author field",
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":null}`,
wantAuthor: nil,
},
{
name: "invalid author type",
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":123}`,
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var data Data
err := json.Unmarshal([]byte(tc.input), &data)
if tc.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantAuthor, data.Author)
})
}
}

func TestValidatePRInfoV1_3(t *testing.T) {
testCases := []struct {
name string
data string
wantErr bool
}{
{
name: "v1.3 author as object",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"author": {"login": "dependabot[bot]", "type": "Bot"}
}`,
wantErr: false,
},
{
name: "v1.3 author as string (backwards compat)",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"author": "username"
}`,
wantErr: false,
},
{
name: "v1.3 author object missing type",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"author": {"login": "username"}
}`,
wantErr: true,
},
{
name: "v1.3 author object invalid type",
data: `{
"platform": "github",
"type": "pull_request",
"number": "123",
"url": "https://github.com/owner/repo/pull/123",
"author": {"login": "username", "type": "InvalidType"}
}`,
wantErr: true,
},
{
name: "v1.3 with reviewers and object author",
data: `{
"platform": "github",
"type": "pull_request",
"number": "789",
"url": "https://github.com/owner/repo/pull/789",
"author": {"login": "renovate[bot]", "type": "Bot"},
"reviewers": [
{"login": "reviewer1", "type": "User", "requested": true, "review_status": "APPROVED"}
]
}`,
wantErr: false,
},
}

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_3)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.chainloop.dev/prinfo/1.3/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": {
"oneOf": [
{
"type": "string",
"description": "Username of the PR/MR author (deprecated, use object form)"
},
{
"type": "object",
"properties": {
"login": {
"type": "string",
"description": "Username of the PR/MR author"
},
"type": {
"type": "string",
"enum": [
"User",
"Bot",
"unknown"
],
"description": "Account type of the PR/MR author"
}
},
"required": [
"login",
"type"
],
"additionalProperties": false,
"description": "The PR/MR author with account type"
}
],
"description": "The PR/MR author (string for backwards compatibility, or object with login and type)"
},
"reviewers": {
"items": {
"properties": {
"login": {
"type": "string",
"description": "Username of the reviewer"
},
"type": {
"type": "string",
"enum": [
"User",
"Bot",
"unknown"
],
"description": "Account type of the reviewer"
},
"requested": {
"type": "boolean",
"description": "Whether the reviewer was explicitly requested to review"
},
"review_status": {
"type": "string",
"enum": [
"APPROVED",
"CHANGES_REQUESTED",
"COMMENTED",
"DISMISSED",
"PENDING"
],
"description": "The reviewer's current review state if they have submitted a review"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"login",
"type",
"requested"
]
},
"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 @@ -52,6 +52,8 @@ const (
PRInfoVersion1_1 PRInfoVersion = "1.1"
// PRInfoVersion1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers).
PRInfoVersion1_2 PRInfoVersion = "1.2"
// PRInfoVersion1_3 represents PR/MR Info version 1.3 schema (author as object with type).
PRInfoVersion1_3 PRInfoVersion = "1.3"
// 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 @@ -100,6 +102,8 @@ var (
prInfoSpecVersion1_1 string
//go:embed internal_schemas/prinfo/pr-info-1.2.schema.json
prInfoSpecVersion1_2 string
//go:embed internal_schemas/prinfo/pr-info-1.3.schema.json
prInfoSpecVersion1_3 string

// AI Agent Config schemas
//go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json
Expand All @@ -125,6 +129,7 @@ var schemaURLMapping = map[string]string{
"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/prinfo/1.2/pr-info.schema.json": prInfoSpecVersion1_2,
"https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json": prInfoSpecVersion1_3,
"https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion0_1,
}

Expand Down Expand Up @@ -155,6 +160,7 @@ func init() {
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")
compiledPRInfoSchemas[PRInfoVersion1_2] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json")
compiledPRInfoSchemas[PRInfoVersion1_3] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/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 @@ -252,7 +258,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_2
version = PRInfoVersion1_3
}

schema, ok := compiledPRInfoSchemas[version]
Expand Down
Loading
Loading