1
+ #!/usr/bin/env python3
2
+ import os
3
+ import glob
4
+ import json
5
+ import requests
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import List , Dict , Optional , Union
9
+ from dataclasses import dataclass
10
+ import xml .etree .ElementTree as ET
11
+ import yaml
12
+ from dotenv import load_dotenv
13
+ import argparse
14
+
15
+
16
+
17
+ @dataclass
18
+ class KetryxConfig :
19
+ ketryx_url : str
20
+ project : str
21
+ api_key : str
22
+ commit_sha : Optional [str ]
23
+ version : Optional [str ]
24
+
25
+
26
+ class KetryxReporter :
27
+ def __init__ (self , config : KetryxConfig ):
28
+ self .config = config
29
+ self .base_url = config .ketryx_url .rstrip ('/' )
30
+ self .headers = {
31
+ 'Authorization' : f'Bearer { config .api_key } ' ,
32
+ 'Content-Type' : 'application/json'
33
+ }
34
+
35
+ def upload_artifact (self , file_path : str , content_type : str = 'application/octet-stream' ) -> str :
36
+ """Upload a single artifact file to Ketryx."""
37
+ url = f"{ self .base_url } /api/v1/build-artifacts"
38
+ params = {'project' : self .config .project }
39
+
40
+ with open (file_path , 'rb' ) as f :
41
+ files = {'file' : (os .path .basename (file_path ), f , content_type )}
42
+ response = requests .post (url , params = params , headers = {'Authorization' : self .headers ['Authorization' ]}, files = files )
43
+
44
+ if response .status_code != 200 :
45
+ raise Exception (f"Failed to upload artifact: { response .text } " )
46
+
47
+ return response .json ()['id' ]
48
+
49
+ def upload_test_results (self , junit_paths : List [str ], cucumber_paths : List [str ]) -> List [Dict ]:
50
+ """Upload all test results and return artifact data."""
51
+ artifacts = []
52
+
53
+ # Upload JUnit XML results
54
+ for pattern in junit_paths :
55
+ for file_path in glob .glob (pattern ):
56
+ artifact_id = self .upload_artifact (file_path , 'application/xml' )
57
+ artifacts .append ({
58
+ 'id' : artifact_id ,
59
+ 'type' : 'junit-xml'
60
+ })
61
+
62
+ # Upload Cucumber JSON results
63
+ for pattern in cucumber_paths :
64
+ for file_path in glob .glob (pattern ):
65
+ artifact_id = self .upload_artifact (file_path , 'application/json' )
66
+ artifacts .append ({
67
+ 'id' : artifact_id ,
68
+ 'type' : 'cucumber-json'
69
+ })
70
+
71
+ return artifacts
72
+
73
+ def upload_sbom (self , sbom_paths : List [str ], sbom_type : str ) -> List [Dict ]:
74
+ """Upload SBOM files.
75
+
76
+ Args:
77
+ sbom_paths: List of file paths to SBOM files
78
+ sbom_type: Type of SBOM (e.g., 'cyclonedx', 'spdx')
79
+ """
80
+ artifacts = []
81
+
82
+ for pattern in sbom_paths :
83
+ for file_path in glob .glob (pattern ):
84
+ artifact_id = self .upload_artifact (file_path , 'application/json' )
85
+ artifacts .append ({
86
+ 'id' : artifact_id ,
87
+ 'type' : f'{ sbom_type } -json'
88
+ })
89
+
90
+ return artifacts
91
+
92
+ def report_build (self , build_name : str , artifacts : List [Dict ]) -> Dict :
93
+ """Report build data to Ketryx."""
94
+ url = f"{ self .base_url } /api/v1/builds"
95
+
96
+ data = {
97
+ 'project' : self .config .project ,
98
+ 'buildName' : build_name ,
99
+ 'artifacts' : artifacts ,
100
+ 'sourceUrl' : os .getenv ('GITHUB_SERVER_URL' , '' ),
101
+ 'repositoryUrls' : [f"{ os .getenv ('GITHUB_SERVER_URL' , '' )} /{ os .getenv ('GITHUB_REPOSITORY' , '' )} " ],
102
+ }
103
+
104
+ # If version is specified, it takes precedence over commitSha
105
+ if self .config .version :
106
+ data ['version' ] = self .config .version
107
+ elif self .config .commit_sha :
108
+ data ['commitSha' ] = self .config .commit_sha
109
+
110
+ response = requests .post (url , headers = self .headers , json = data )
111
+ if response .status_code != 200 :
112
+ raise Exception (f"Failed to report build: { response .text } " )
113
+
114
+ return response .json ()
115
+
116
+ def process_build_config (config_file : str ) -> List [Dict ]:
117
+ """Process the YAML config file and return build artifacts."""
118
+ with open (config_file , 'r' ) as f :
119
+ build_config = yaml .safe_load (f )
120
+
121
+ if not build_config or 'builds' not in build_config :
122
+ raise ValueError ("Invalid config file: missing 'builds' section" )
123
+
124
+ # Collect all missing files
125
+ missing_files = []
126
+
127
+ for build in build_config ['builds' ]:
128
+ if build ['type' ] == 'test-results' :
129
+ for junit_path in build ['artifacts' ].get ('junit' , []):
130
+ if not glob .glob (junit_path ):
131
+ missing_files .append (f"JUnit file not found: { junit_path } " )
132
+ for cucumber_path in build ['artifacts' ].get ('cucumber' , []):
133
+ if not glob .glob (cucumber_path ):
134
+ missing_files .append (f"Cucumber file not found: { cucumber_path } " )
135
+ elif build ['type' ] == 'sbom' :
136
+ for artifact in build ['artifacts' ]:
137
+ if not glob .glob (artifact ['file' ]):
138
+ missing_files .append (f"SBOM file not found: { artifact ['file' ]} " )
139
+
140
+ if missing_files :
141
+ print ("\n Missing files:" )
142
+ for file in missing_files :
143
+ print (f" - { file } " )
144
+ import sys ; sys .exit (1 )
145
+
146
+ return build_config ['builds' ]
147
+
148
+ def main ():
149
+ # Add argument parser
150
+ parser = argparse .ArgumentParser (description = 'Process build configuration and report to Ketryx' )
151
+ parser .add_argument ('config_file' , help = 'Path to the YAML configuration file' )
152
+ args = parser .parse_args ()
153
+
154
+ # Load environment variables from .env file
155
+ load_dotenv ()
156
+
157
+ # Configuration from environment variables
158
+ config = KetryxConfig (
159
+ ketryx_url = os .getenv ('KETRYX_URL' ),
160
+ project = os .getenv ('KETRYX_PROJECT' ),
161
+ api_key = os .getenv ('KETRYX_API_KEY' ),
162
+ version = os .getenv ('KETRYX_VERSION' ),
163
+ commit_sha = os .getenv ('GITHUB_SHA' )
164
+ )
165
+
166
+ if not all ([config .project , config .api_key , config .version ]):
167
+ raise ValueError ("Missing required environment variables" )
168
+
169
+ reporter = KetryxReporter (config )
170
+
171
+ # Run tests and collect results
172
+ try :
173
+ builds = process_build_config (args .config_file )
174
+
175
+ for build in builds :
176
+ if build ['type' ] == 'test-results' :
177
+ artifacts = reporter .upload_test_results (
178
+ junit_paths = build ['artifacts' ].get ('junit' , []),
179
+ cucumber_paths = build ['artifacts' ].get ('cucumber' , [])
180
+ )
181
+ elif build ['type' ] == 'sbom' :
182
+ for artifact in build ['artifacts' ]:
183
+ artifacts = reporter .upload_sbom (
184
+ sbom_paths = [artifact ['file' ]],
185
+ sbom_type = artifact ['type' ]
186
+ )
187
+ else :
188
+ print (f"Warning: Unknown build type '{ build ['type' ]} '" )
189
+ continue
190
+
191
+ build_result = reporter .report_build (build ['name' ], artifacts )
192
+ print (f"Reported { build ['name' ]} to Ketryx: { build_result .get ('buildId' )} " )
193
+
194
+ except Exception as e :
195
+ print (f"Error: { e } " )
196
+ raise
197
+
198
+ if __name__ == '__main__' :
199
+ main ()
0 commit comments