From b7a492471d064346adac9ba25d811f5e566e19f3 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 28 Oct 2026 18:58:23 +0100 Subject: [PATCH] feat(prinfo): add the PR/MR state in schema 1.4 Add an optional state field (open, closed, merged) to the PR/MR info material. Policies can use it to evaluate a rule only when the PR/MR is merged, for example the minimum number of approvals. Refs: PFM-8051 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 6ef5e9f5-dc0c-45cf-8b3f-70d112a87b7e --- pkg/prinfo/prinfo.go | 10 +- pkg/prinfo/schema.go | 9 +- pkg/prinfo/schema_test.go | 58 +++++++-- pkg/prinfo/schemas/pr-info-1.4.schema.json | 142 +++++++++++++++++++++ 4 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 pkg/prinfo/schemas/pr-info-1.4.schema.json diff --git a/pkg/prinfo/prinfo.go b/pkg/prinfo/prinfo.go index d8686f7e5..ad4207f8c 100644 --- a/pkg/prinfo/prinfo.go +++ b/pkg/prinfo/prinfo.go @@ -24,7 +24,14 @@ 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.3/pr-info.schema.json" + EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.4/pr-info.schema.json" + + // StateOpen is the state of a PR/MR that is not closed or merged + StateOpen = "open" + // StateClosed is the state of a PR/MR that was closed without a merge + StateClosed = "closed" + // StateMerged is the state of a merged PR/MR + StateMerged = "merged" // AuthorTypeUser represents a human user account AuthorTypeUser = "User" @@ -60,6 +67,7 @@ type Data struct { URL string `json:"url" jsonschema:"required,format=uri,description=Direct URL to the PR/MR"` 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"` + State string `json:"state,omitempty" jsonschema:"enum=open,enum=closed,enum=merged,description=The PR/MR state when the data was collected"` } // UnmarshalJSON implements custom JSON unmarshaling for Data to handle diff --git a/pkg/prinfo/schema.go b/pkg/prinfo/schema.go index 123284849..1993d71ac 100644 --- a/pkg/prinfo/schema.go +++ b/pkg/prinfo/schema.go @@ -37,9 +37,11 @@ const ( Version1_2 Version = "1.2" // Version1_3 represents PR/MR Info version 1.3 schema (author as object with type). Version1_3 Version = "1.3" + // Version1_4 represents PR/MR Info version 1.4 schema (adds the PR/MR state). + Version1_4 Version = "1.4" // LatestVersion is the schema version emitted by this package. - LatestVersion = Version1_3 + LatestVersion = Version1_4 ) // ErrInvalidJSONPayload represents an error for an invalid JSON payload. @@ -54,6 +56,8 @@ var ( specVersion1_2 string //go:embed schemas/pr-info-1.3.schema.json specVersion1_3 string + //go:embed schemas/pr-info-1.4.schema.json + specVersion1_4 string ) // rawSchemas holds the canonical JSON schema documents indexed by version. @@ -62,6 +66,7 @@ var rawSchemas = map[Version]string{ Version1_1: specVersion1_1, Version1_2: specVersion1_2, Version1_3: specVersion1_3, + Version1_4: specVersion1_4, } var ( @@ -86,7 +91,7 @@ func Schema(version Version) (string, error) { // Versions returns the supported schema versions, oldest first. func Versions() []Version { - return []Version{Version1_0, Version1_1, Version1_2, Version1_3} + return []Version{Version1_0, Version1_1, Version1_2, Version1_3, Version1_4} } func initSchemas() { diff --git a/pkg/prinfo/schema_test.go b/pkg/prinfo/schema_test.go index 9ca50bc88..c7054214a 100644 --- a/pkg/prinfo/schema_test.go +++ b/pkg/prinfo/schema_test.go @@ -69,6 +69,7 @@ func TestSchema(t *testing.T) { func TestPublishedAuthorShapeIsStable(t *testing.T) { const stringAuthor = `"octocat"` const objectAuthor = `{"login": "octocat", "type": "User"}` + const stringForm, objectForm = "string", "object" testCases := []struct { version prinfo.Version @@ -76,14 +77,16 @@ func TestPublishedAuthorShapeIsStable(t *testing.T) { wantErr bool authorForm string }{ - {version: prinfo.Version1_0, author: stringAuthor, authorForm: "string"}, - {version: prinfo.Version1_0, author: objectAuthor, authorForm: "object", wantErr: true}, - {version: prinfo.Version1_1, author: stringAuthor, authorForm: "string"}, - {version: prinfo.Version1_1, author: objectAuthor, authorForm: "object", wantErr: true}, - {version: prinfo.Version1_2, author: stringAuthor, authorForm: "string"}, - {version: prinfo.Version1_2, author: objectAuthor, authorForm: "object", wantErr: true}, - {version: prinfo.Version1_3, author: stringAuthor, authorForm: "string"}, - {version: prinfo.Version1_3, author: objectAuthor, authorForm: "object"}, + {version: prinfo.Version1_0, author: stringAuthor, authorForm: stringForm}, + {version: prinfo.Version1_0, author: objectAuthor, authorForm: objectForm, wantErr: true}, + {version: prinfo.Version1_1, author: stringAuthor, authorForm: stringForm}, + {version: prinfo.Version1_1, author: objectAuthor, authorForm: objectForm, wantErr: true}, + {version: prinfo.Version1_2, author: stringAuthor, authorForm: stringForm}, + {version: prinfo.Version1_2, author: objectAuthor, authorForm: objectForm, wantErr: true}, + {version: prinfo.Version1_3, author: stringAuthor, authorForm: stringForm}, + {version: prinfo.Version1_3, author: objectAuthor, authorForm: objectForm}, + {version: prinfo.Version1_4, author: stringAuthor, authorForm: stringForm}, + {version: prinfo.Version1_4, author: objectAuthor, authorForm: objectForm}, } for _, tc := range testCases { @@ -109,6 +112,45 @@ func TestPublishedAuthorShapeIsStable(t *testing.T) { } } +// 1.4 adds the optional PR/MR state. Older versions reject it, because they do +// not allow unknown properties. +func TestValidateState(t *testing.T) { + testCases := []struct { + name string + version prinfo.Version + state string + wantErr bool + }{ + {name: "open", version: prinfo.Version1_4, state: prinfo.StateOpen}, + {name: "closed", version: prinfo.Version1_4, state: prinfo.StateClosed}, + {name: "merged", version: prinfo.Version1_4, state: prinfo.StateMerged}, + {name: "unknown state", version: prinfo.Version1_4, state: "draft", wantErr: true}, + {name: "state in 1.3", version: prinfo.Version1_3, state: prinfo.StateMerged, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload := `{ + "platform": "github", + "type": "pull_request", + "number": "123", + "url": "https://github.com/owner/repo/pull/123", + "state": "` + tc.state + `" + }` + + var data any + require.NoError(t, json.Unmarshal([]byte(payload), &data)) + + err := prinfo.Validate(data, tc.version) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + func TestNewEvidence(t *testing.T) { evidence := prinfo.NewEvidence(prinfo.Data{ Platform: "github", diff --git a/pkg/prinfo/schemas/pr-info-1.4.schema.json b/pkg/prinfo/schemas/pr-info-1.4.schema.json new file mode 100644 index 000000000..28a89832f --- /dev/null +++ b/pkg/prinfo/schemas/pr-info-1.4.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schemas.chainloop.dev/prinfo/1.4/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" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ], + "description": "The PR/MR state when the data was collected" + } + }, + "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" +}