Skip to content

Commit 8692875

Browse files
authored
feat(prinfo): expand author field to include account type (#2934)
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent 4ac5faa commit 8692875

File tree

8 files changed

+453
-49
lines changed

8 files changed

+453
-49
lines changed

internal/prinfo/prinfo.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,31 @@
1515

1616
package prinfo
1717

18+
import (
19+
"encoding/json"
20+
"fmt"
21+
)
22+
1823
const (
1924
// EvidenceID is the identifier for the PR/MR info material type
2025
EvidenceID = "CHAINLOOP_PR_INFO"
2126
// EvidenceSchemaURL is the URL to the JSON schema for PR/MR info
22-
EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json"
27+
EvidenceSchemaURL = "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json"
28+
29+
// AuthorTypeUser represents a human user account
30+
AuthorTypeUser = "User"
31+
// AuthorTypeBot represents a bot/service account
32+
AuthorTypeBot = "Bot"
33+
// AuthorTypeUnknown represents an account with unknown type
34+
AuthorTypeUnknown = "unknown"
2335
)
2436

37+
// Author represents the author of the PR/MR with account type information
38+
type Author struct {
39+
Login string `json:"login" jsonschema:"required,description=Username of the PR/MR author"`
40+
Type string `json:"type" jsonschema:"required,enum=User,enum=Bot,enum=unknown,description=Account type of the PR/MR author"`
41+
}
42+
2543
// Reviewer represents a reviewer of the PR/MR
2644
type Reviewer struct {
2745
Login string `json:"login" jsonschema:"required,description=Username of the reviewer"`
@@ -40,10 +58,48 @@ type Data struct {
4058
SourceBranch string `json:"source_branch" jsonschema:"description=The source branch name"`
4159
TargetBranch string `json:"target_branch" jsonschema:"description=The target branch name"`
4260
URL string `json:"url" jsonschema:"required,format=uri,description=Direct URL to the PR/MR"`
43-
Author string `json:"author" jsonschema:"description=Username of the PR/MR author"`
61+
Author *Author `json:"author,omitempty" jsonschema:"description=The PR/MR author"`
4462
Reviewers []Reviewer `json:"reviewers,omitempty" jsonschema:"description=List of reviewers who reviewed or were requested to review"`
4563
}
4664

65+
// UnmarshalJSON implements custom JSON unmarshaling for Data to handle
66+
// backwards compatibility where author can be either a string (v1.0-v1.2)
67+
// or an object (v1.3+).
68+
func (d *Data) UnmarshalJSON(b []byte) error {
69+
// Use an alias to avoid infinite recursion
70+
type Alias Data
71+
aux := &struct {
72+
Author json.RawMessage `json:"author,omitempty"`
73+
*Alias
74+
}{
75+
Alias: (*Alias)(d),
76+
}
77+
78+
if err := json.Unmarshal(b, aux); err != nil {
79+
return err
80+
}
81+
82+
if len(aux.Author) == 0 || string(aux.Author) == "null" {
83+
return nil
84+
}
85+
86+
// Try object format first
87+
var author Author
88+
if err := json.Unmarshal(aux.Author, &author); err == nil {
89+
d.Author = &author
90+
return nil
91+
}
92+
93+
// Fall back to string format
94+
var login string
95+
if err := json.Unmarshal(aux.Author, &login); err == nil {
96+
d.Author = &Author{Login: login, Type: AuthorTypeUnknown}
97+
return nil
98+
}
99+
100+
return fmt.Errorf("author field must be a string or an object with login and type fields")
101+
}
102+
47103
// Evidence represents the complete evidence structure for PR/MR metadata
48104
type Evidence struct {
49105
ID string `json:"chainloop.material.evidence.id"`

internal/prinfo/prinfo_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,133 @@ func TestValidatePRInfoV1_0BackwardCompat(t *testing.T) {
308308
})
309309
}
310310
}
311+
312+
func TestDataUnmarshalJSON(t *testing.T) {
313+
testCases := []struct {
314+
name string
315+
input string
316+
wantAuthor *Author
317+
wantErr bool
318+
}{
319+
{
320+
name: "string author (v1.0-v1.2 backwards compat)",
321+
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":"dependabot[bot]"}`,
322+
wantAuthor: &Author{Login: "dependabot[bot]", Type: "unknown"},
323+
},
324+
{
325+
name: "object author (v1.3)",
326+
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":{"login":"dependabot[bot]","type":"Bot"}}`,
327+
wantAuthor: &Author{Login: "dependabot[bot]", Type: "Bot"},
328+
},
329+
{
330+
name: "no author field",
331+
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1"}`,
332+
wantAuthor: nil,
333+
},
334+
{
335+
name: "null author field",
336+
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":null}`,
337+
wantAuthor: nil,
338+
},
339+
{
340+
name: "invalid author type",
341+
input: `{"platform":"github","type":"pull_request","number":"1","url":"https://github.com/o/r/pull/1","author":123}`,
342+
wantErr: true,
343+
},
344+
}
345+
346+
for _, tc := range testCases {
347+
t.Run(tc.name, func(t *testing.T) {
348+
var data Data
349+
err := json.Unmarshal([]byte(tc.input), &data)
350+
if tc.wantErr {
351+
assert.Error(t, err)
352+
return
353+
}
354+
require.NoError(t, err)
355+
assert.Equal(t, tc.wantAuthor, data.Author)
356+
})
357+
}
358+
}
359+
360+
func TestValidatePRInfoV1_3(t *testing.T) {
361+
testCases := []struct {
362+
name string
363+
data string
364+
wantErr bool
365+
}{
366+
{
367+
name: "v1.3 author as object",
368+
data: `{
369+
"platform": "github",
370+
"type": "pull_request",
371+
"number": "123",
372+
"url": "https://github.com/owner/repo/pull/123",
373+
"author": {"login": "dependabot[bot]", "type": "Bot"}
374+
}`,
375+
wantErr: false,
376+
},
377+
{
378+
name: "v1.3 author as string (backwards compat)",
379+
data: `{
380+
"platform": "github",
381+
"type": "pull_request",
382+
"number": "123",
383+
"url": "https://github.com/owner/repo/pull/123",
384+
"author": "username"
385+
}`,
386+
wantErr: false,
387+
},
388+
{
389+
name: "v1.3 author object missing type",
390+
data: `{
391+
"platform": "github",
392+
"type": "pull_request",
393+
"number": "123",
394+
"url": "https://github.com/owner/repo/pull/123",
395+
"author": {"login": "username"}
396+
}`,
397+
wantErr: true,
398+
},
399+
{
400+
name: "v1.3 author object invalid type",
401+
data: `{
402+
"platform": "github",
403+
"type": "pull_request",
404+
"number": "123",
405+
"url": "https://github.com/owner/repo/pull/123",
406+
"author": {"login": "username", "type": "InvalidType"}
407+
}`,
408+
wantErr: true,
409+
},
410+
{
411+
name: "v1.3 with reviewers and object author",
412+
data: `{
413+
"platform": "github",
414+
"type": "pull_request",
415+
"number": "789",
416+
"url": "https://github.com/owner/repo/pull/789",
417+
"author": {"login": "renovate[bot]", "type": "Bot"},
418+
"reviewers": [
419+
{"login": "reviewer1", "type": "User", "requested": true, "review_status": "APPROVED"}
420+
]
421+
}`,
422+
wantErr: false,
423+
},
424+
}
425+
426+
for _, tc := range testCases {
427+
t.Run(tc.name, func(t *testing.T) {
428+
var data interface{}
429+
err := json.Unmarshal([]byte(tc.data), &data)
430+
require.NoError(t, err)
431+
432+
err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_3)
433+
if tc.wantErr {
434+
assert.Error(t, err)
435+
} else {
436+
assert.NoError(t, err)
437+
}
438+
})
439+
}
440+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json",
4+
"properties": {
5+
"platform": {
6+
"type": "string",
7+
"enum": [
8+
"github",
9+
"gitlab"
10+
],
11+
"description": "The CI/CD platform"
12+
},
13+
"type": {
14+
"type": "string",
15+
"enum": [
16+
"pull_request",
17+
"merge_request"
18+
],
19+
"description": "The type of change request"
20+
},
21+
"number": {
22+
"type": "string",
23+
"description": "The PR/MR number or identifier"
24+
},
25+
"title": {
26+
"type": "string",
27+
"description": "The PR/MR title"
28+
},
29+
"description": {
30+
"type": "string",
31+
"description": "The PR/MR description or body"
32+
},
33+
"source_branch": {
34+
"type": "string",
35+
"description": "The source branch name"
36+
},
37+
"target_branch": {
38+
"type": "string",
39+
"description": "The target branch name"
40+
},
41+
"url": {
42+
"type": "string",
43+
"format": "uri",
44+
"description": "Direct URL to the PR/MR"
45+
},
46+
"author": {
47+
"oneOf": [
48+
{
49+
"type": "string",
50+
"description": "Username of the PR/MR author (deprecated, use object form)"
51+
},
52+
{
53+
"type": "object",
54+
"properties": {
55+
"login": {
56+
"type": "string",
57+
"description": "Username of the PR/MR author"
58+
},
59+
"type": {
60+
"type": "string",
61+
"enum": [
62+
"User",
63+
"Bot",
64+
"unknown"
65+
],
66+
"description": "Account type of the PR/MR author"
67+
}
68+
},
69+
"required": [
70+
"login",
71+
"type"
72+
],
73+
"additionalProperties": false,
74+
"description": "The PR/MR author with account type"
75+
}
76+
],
77+
"description": "The PR/MR author (string for backwards compatibility, or object with login and type)"
78+
},
79+
"reviewers": {
80+
"items": {
81+
"properties": {
82+
"login": {
83+
"type": "string",
84+
"description": "Username of the reviewer"
85+
},
86+
"type": {
87+
"type": "string",
88+
"enum": [
89+
"User",
90+
"Bot",
91+
"unknown"
92+
],
93+
"description": "Account type of the reviewer"
94+
},
95+
"requested": {
96+
"type": "boolean",
97+
"description": "Whether the reviewer was explicitly requested to review"
98+
},
99+
"review_status": {
100+
"type": "string",
101+
"enum": [
102+
"APPROVED",
103+
"CHANGES_REQUESTED",
104+
"COMMENTED",
105+
"DISMISSED",
106+
"PENDING"
107+
],
108+
"description": "The reviewer's current review state if they have submitted a review"
109+
}
110+
},
111+
"additionalProperties": false,
112+
"type": "object",
113+
"required": [
114+
"login",
115+
"type",
116+
"requested"
117+
]
118+
},
119+
"type": "array",
120+
"description": "List of reviewers who reviewed or were requested to review"
121+
}
122+
},
123+
"additionalProperties": false,
124+
"type": "object",
125+
"required": [
126+
"platform",
127+
"type",
128+
"number",
129+
"url"
130+
],
131+
"title": "Pull Request / Merge Request Information",
132+
"description": "Schema for Pull Request or Merge Request metadata collected during attestation"
133+
}

internal/schemavalidators/schemavalidators.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const (
5252
PRInfoVersion1_1 PRInfoVersion = "1.1"
5353
// PRInfoVersion1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers).
5454
PRInfoVersion1_2 PRInfoVersion = "1.2"
55+
// PRInfoVersion1_3 represents PR/MR Info version 1.3 schema (author as object with type).
56+
PRInfoVersion1_3 PRInfoVersion = "1.3"
5557
// CycloneDXVersion1_5 represents CycloneDX version 1.5 schema.
5658
CycloneDXVersion1_5 CycloneDXVersion = "1.5"
5759
// CycloneDXVersion1_6 represents CycloneDX version 1.6 schema.
@@ -100,6 +102,8 @@ var (
100102
prInfoSpecVersion1_1 string
101103
//go:embed internal_schemas/prinfo/pr-info-1.2.schema.json
102104
prInfoSpecVersion1_2 string
105+
//go:embed internal_schemas/prinfo/pr-info-1.3.schema.json
106+
prInfoSpecVersion1_3 string
103107

104108
// AI Agent Config schemas
105109
//go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json
@@ -125,6 +129,7 @@ var schemaURLMapping = map[string]string{
125129
"https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0,
126130
"https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json": prInfoSpecVersion1_1,
127131
"https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json": prInfoSpecVersion1_2,
132+
"https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json": prInfoSpecVersion1_3,
128133
"https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion0_1,
129134
}
130135

@@ -155,6 +160,7 @@ func init() {
155160
compiledPRInfoSchemas[PRInfoVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json")
156161
compiledPRInfoSchemas[PRInfoVersion1_1] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json")
157162
compiledPRInfoSchemas[PRInfoVersion1_2] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json")
163+
compiledPRInfoSchemas[PRInfoVersion1_3] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json")
158164

159165
compiledAIAgentConfigSchemas = make(map[AIAgentConfigVersion]*jsonschema.Schema)
160166
compiledAIAgentConfigSchemas[AIAgentConfigVersion0_1] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json")
@@ -252,7 +258,7 @@ func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersi
252258
// ValidatePRInfo validates the PR/MR info schema.
253259
func ValidatePRInfo(data interface{}, version PRInfoVersion) error {
254260
if version == "" {
255-
version = PRInfoVersion1_2
261+
version = PRInfoVersion1_3
256262
}
257263

258264
schema, ok := compiledPRInfoSchemas[version]

0 commit comments

Comments
 (0)