-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathmain.go
More file actions
619 lines (522 loc) · 17.7 KB
/
main.go
File metadata and controls
619 lines (522 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
package main
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/boyter/scc/v3/processor"
"golang.org/x/exp/slog"
)
const mode os.FileMode = 0755
// Version information (set during build)
var (
version = "dev"
)
//go:embed posix/* windows/*
var scriptFS embed.FS // embedding both posix and windows directory scripts to be available to the binary
type CodeMetrics struct {
Lines int64 `json:"lines"`
Code int64 `json:"code"`
Comments int64 `json:"comments"`
Blanks int64 `json:"blanks"`
Complexity int64 `json:"complexity"`
Files int64 `json:"files"`
Languages map[string]LanguageMetrics `json:"languages"`
}
// LanguageMetrics represents metrics per programming language
type LanguageMetrics struct {
Lines int64 `json:"lines"`
Code int64 `json:"code"`
Comments int64 `json:"comments"`
Blanks int64 `json:"blanks"`
Complexity int64 `json:"complexity"`
Files int64 `json:"files"`
}
// BuildToolData represents the complete build tool and metrics data written to file
type BuildToolData struct {
// Existing fields from get-buildtool-lang script
HarnessLang string `json:"harness_lang"`
HarnessBuildTool string `json:"harness_build_tool"`
// New telemetry fields
Repository string `json:"repository,omitempty"`
BuildEvent string `json:"build_event,omitempty"`
BuildEventValue string `json:"build_event_value,omitempty"`
Metrics CodeMetrics `json:"metrics"`
PluginVersion string `json:"plugin_version"`
}
func writeScriptsToTemp(tmpDir string) error {
// Walk through the embedded filesystem and write all files
return fs.WalkDir(scriptFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory
if path == "." {
return nil
}
dstPath := filepath.Join(tmpDir, path)
if d.IsDir() {
return os.MkdirAll(dstPath, mode)
}
// Read and write the file
content, err := scriptFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read embedded file %s: %v", path, err)
}
if err := os.MkdirAll(filepath.Dir(dstPath), mode); err != nil {
return fmt.Errorf("failed to create directory %s: %v", filepath.Dir(dstPath), err)
}
if err := os.WriteFile(dstPath, content, mode); err != nil {
return fmt.Errorf("failed to write file %s: %v", dstPath, err)
}
return nil
})
}
// Global temp directory for script storage (shared between functions)
var globalTmpDir string
// cleanupTempDir safely removes the temp directory if it exists
func cleanupTempDir() {
if globalTmpDir != "" {
if err := os.RemoveAll(globalTmpDir); err != nil {
slog.Warn("Failed to cleanup temp directory", "dir", globalTmpDir, "error", err)
} else {
slog.Debug("Cleaned up temp directory", "dir", globalTmpDir)
}
globalTmpDir = ""
}
}
// findPowerShell locates a valid PowerShell executable.
// It prioritizes PowerShell Core (pwsh) over Windows PowerShell (powershell).
func findPowerShell() string {
// Define the candidates to look for
candidates := []string{"pwsh", "powershell", "powershell.exe"}
for _, candidate := range candidates {
if path, err := exec.LookPath(candidate); err == nil {
slog.Debug("Found PowerShell executable", "path", path)
return path
}
}
slog.Warn("no suitable PowerShell executable found (checked: %v). Using pwsh as default", candidates)
return "pwsh"
}
func runGitClone() error {
var err error
// Create a unique temporary subdirectory (keep alive for script reuse in metrics)
// Use HARNESS_WORKDIR as base directory if set, otherwise use system temp
baseTmpDir := ""
if workdir := os.Getenv("HARNESS_WORKDIR"); workdir != "" {
baseTmpDir = workdir
}
globalTmpDir, err = os.MkdirTemp(baseTmpDir, "drone-git-*")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
if err := writeScriptsToTemp(globalTmpDir); err != nil {
return err
}
ctx := context.Background()
// current working directory (workspace)
workdir, err := os.Getwd()
if err != nil {
slog.Error("cannot get workdir", "error", err)
os.Exit(1)
}
switch runtime.GOOS {
case "windows":
// Find safe PowerShell executable
psExe := findPowerShell()
scriptPath := filepath.Join(globalTmpDir, "windows", "clone.ps1")
script := fmt.Sprintf(
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; %s",
scriptPath)
cmd := exec.CommandContext(ctx, psExe, "-Command", script)
return runCmds([]*exec.Cmd{cmd}, os.Environ(), workdir, os.Stdout, os.Stderr)
case "linux", "darwin":
shell := "bash"
if _, err := exec.LookPath("bash"); err != nil {
shell = "sh"
}
scriptPath := filepath.Join(globalTmpDir, "posix", "script")
cmd := exec.CommandContext(ctx, shell, scriptPath)
return runCmds([]*exec.Cmd{cmd}, os.Environ(), workdir, os.Stdout, os.Stderr)
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
func runCmds(cmds []*exec.Cmd, env []string, workdir string,
stdout io.Writer, stderr io.Writer) error {
for _, cmd := range cmds {
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Env = env
cmd.Dir = workdir
trace(cmd)
if err := cmd.Run(); err != nil {
return err
}
}
return nil
}
func trace(cmd *exec.Cmd) {
s := fmt.Sprintf("+ %s\n", strings.Join(cmd.Args, " "))
slog.Debug(s)
}
func collectCodeMetrics(workdir string) (*CodeMetrics, error) {
// Set up timeout for analysis
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Configure scc processor with optimizations
processor.DirFilePaths = []string{workdir}
processor.Format = "json"
processor.Files = false
processor.Complexity = true // Disable complexity calculations
processor.Cocomo = true // Disable COCOMO calculations for speed
processor.Size = true // Disable size calculations for speed
processor.Duplicates = false // Disable duplicate detection for speed
// Configure exclusions for performance
processor.PathDenyList = []string{
"node_modules", "vendor", "target", "build", ".git",
"__pycache__", ".gradle", ".m2", "coverage", "dist",
".svn", ".hg", "bin", "obj", "Debug", "Release",
}
processor.GitIgnore = false
processor.NoLarge = true
processor.LargeByteCount = 1000000 // Skip files > 1MB
processor.LargeLineCount = 40000 // Skip files > 40k lines
// Channel to capture results
done := make(chan error, 1)
var results []processor.LanguageSummary
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("analysis panicked: %v", r)
}
}()
// Capture stdout to get JSON results
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
done <- fmt.Errorf("failed to create pipe: %v", err)
return
}
os.Stdout = w
// Run scc analysis
processor.ConfigureLazy(true)
processor.Process()
// Restore stdout and read results
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
// Parse JSON results
if err := json.Unmarshal(buf.Bytes(), &results); err != nil {
done <- fmt.Errorf("failed to parse analysis results: %v", err)
return
}
done <- nil
}()
// Wait for completion or timeout
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-ctx.Done():
return nil, fmt.Errorf("analysis timed out after 5 seconds")
}
languages := make(map[string]LanguageMetrics)
var totalLines, totalCode, totalComments, totalBlanks, totalComplexity, totalFiles int64
for _, result := range results {
if result.Name == "Total" {
continue // Skip total row, we calculate our own
}
language := result.Name
langMetrics := LanguageMetrics{
Lines: int64(result.Lines),
Code: int64(result.Code),
Comments: int64(result.Comment),
Blanks: int64(result.Blank),
Complexity: int64(result.Complexity),
Files: int64(result.Count),
}
languages[language] = langMetrics
// Update totals
totalLines += langMetrics.Lines
totalCode += langMetrics.Code
totalComments += langMetrics.Comments
totalBlanks += langMetrics.Blanks
totalComplexity += langMetrics.Complexity
totalFiles += langMetrics.Files
}
metrics := &CodeMetrics{
Lines: totalLines,
Code: totalCode,
Comments: totalComments,
Blanks: totalBlanks,
Complexity: totalComplexity,
Files: totalFiles,
Languages: languages,
}
return metrics, nil
}
// executeBuildToolScript executes the get-buildtool-lang script from temp directory
func executeBuildToolScript(workdir string) error {
buildToolFile := os.Getenv("PLUGIN_BUILD_TOOL_FILE")
if buildToolFile == "" {
return nil // No file specified, nothing to do
}
// Scripts are already extracted to globalTmpDir by runGitClone()
if globalTmpDir == "" {
return fmt.Errorf("temp directory not initialized - runGitClone() must be called first")
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
// Find safe PowerShell executable
psExe := findPowerShell()
// Execute PowerShell script from temp directory (no workspace pollution)
scriptPath := filepath.Join(globalTmpDir, "windows", "get-buildtool-lang.ps1")
cmd = exec.Command(psExe, "-File", scriptPath, workdir)
case "linux", "darwin":
// Execute shell script from temp directory (no workspace pollution)
scriptPath := filepath.Join(globalTmpDir, "posix", "get-buildtool-lang")
shell := "bash"
if _, err := exec.LookPath("bash"); err != nil {
shell = "sh"
}
cmd = exec.Command(shell, scriptPath, workdir)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
cmd.Dir = workdir
cmd.Env = os.Environ()
if err := cmd.Run(); err != nil {
slog.Warn("Build tool script execution failed", "error", err)
return err
}
slog.Debug("Build tool script executed successfully")
return nil
}
// tryCollectAndWriteMetrics attempts to collect code metrics and write build tool file
func tryCollectAndWriteMetrics(workdir string) error {
buildToolFile := os.Getenv("PLUGIN_BUILD_TOOL_FILE")
if buildToolFile == "" {
slog.Debug("No PLUGIN_BUILD_TOOL_FILE specified, skipping metrics collection")
return nil
}
// Respect CI_DISABLE_TELEMETRY flag - disables everything (same as original script condition)
if os.Getenv("CI_DISABLE_TELEMETRY") != "" {
slog.Debug("All telemetry disabled via CI_DISABLE_TELEMETRY, skipping collection")
return nil
}
// Skip metrics collection if temp directory not available (runGitClone not called)
if globalTmpDir == "" {
slog.Debug("No temp directory available, skipping metrics collection")
return nil
}
// Always execute build tool script first (basic harness_lang, harness_build_tool data)
if err := executeBuildToolScript(workdir); err != nil {
slog.Warn("Build tool script failed, continuing with empty values", "error", err)
}
// Read the build tool script output
existingData := make(map[string]interface{})
if data, err := os.ReadFile(buildToolFile); err == nil {
if err := json.Unmarshal(data, &existingData); err != nil {
slog.Warn("Failed to parse script output", "error", err)
}
}
// Extract values from script output
harnessLang, _ := existingData["harness_lang"].(string)
harnessBuildTool, _ := existingData["harness_build_tool"].(string)
// Collect SCC metrics only if not specifically disabled
var metrics *CodeMetrics
if os.Getenv("DISABLE_SCC_METRICS") != "" {
slog.Debug("Metrics disabled, using empty metrics")
metrics = &CodeMetrics{
Lines: 0,
Code: 0,
Comments: 0,
Blanks: 0,
Complexity: 0,
Files: 0,
Languages: make(map[string]LanguageMetrics),
}
} else {
var err error
metrics, err = collectCodeMetrics(workdir)
if err != nil {
slog.Warn("Failed to collect metrics", "error", err)
// Use empty metrics if scc fails
metrics = &CodeMetrics{
Lines: 0,
Code: 0,
Comments: 0,
Blanks: 0,
Complexity: 0,
Files: 0,
Languages: make(map[string]LanguageMetrics),
}
}
}
// Get build event and value
buildEvent, buildEventValue := getBuildEventInfo()
// Prepare complete build tool data (always includes build tool info)
buildToolData := &BuildToolData{
// Always include build tool fields (ensures backward compatibility)
HarnessLang: harnessLang,
HarnessBuildTool: harnessBuildTool,
// Context fields
Repository: getRepositoryURL(),
BuildEvent: buildEvent,
BuildEventValue: buildEventValue,
Metrics: *metrics,
PluginVersion: getPluginVersion(),
}
// Always write the file (ensures build tool data flows through)
if err := writeBuildToolFile(buildToolData); err != nil {
return fmt.Errorf("failed to write build tool file: %v", err)
}
return nil
}
// collectAndWriteMetrics is a wrapper for backward compatibility (used by tests)
func collectAndWriteMetrics(workdir string) {
// For tests: initialize temp directory if needed (production skips if not available)
if globalTmpDir == "" {
var err error
// Use HARNESS_WORKDIR as base directory if set, otherwise use system temp
baseTmpDir := ""
if harnessWorkdir := os.Getenv("HARNESS_WORKDIR"); harnessWorkdir != "" {
baseTmpDir = harnessWorkdir
}
globalTmpDir, err = os.MkdirTemp(baseTmpDir, "drone-git-test-*")
if err != nil {
slog.Warn("Failed to create temp directory for test", "error", err)
return
}
if err := writeScriptsToTemp(globalTmpDir); err != nil {
slog.Warn("Failed to extract scripts for test", "error", err)
return
}
slog.Debug("Initialized temp directory for test", "dir", globalTmpDir)
defer cleanupTempDir() // Cleanup after test
}
if err := tryCollectAndWriteMetrics(workdir); err != nil {
slog.Warn("Metrics collection failed", "error", err)
}
}
// writeBuildToolFile writes build tool and metrics data to the specified file
func writeBuildToolFile(data *BuildToolData) error {
buildToolFile := os.Getenv("PLUGIN_BUILD_TOOL_FILE")
if buildToolFile == "" {
slog.Debug("No PLUGIN_BUILD_TOOL_FILE specified, skipping file write")
return nil
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal build tool data: %v", err)
}
if err := os.WriteFile(buildToolFile, jsonData, 0644); err != nil {
return fmt.Errorf("failed to write build tool file %s: %v", buildToolFile, err)
}
return nil
}
// getRepositoryURL extracts the repository URL from Drone environment variables
func getRepositoryURL() string {
// Primary: DRONE_REMOTE_URL is the actual git remote URL used by clone scripts
if url := os.Getenv("DRONE_REMOTE_URL"); url != "" {
return url
}
return "" // No URL available
}
// getPluginVersion returns the plugin version from various sources
func getPluginVersion() string {
// 1. Check if version was set during build (via -ldflags)
if version != "dev" && version != "" {
return version
}
// 3. Fallback to default
return "1.0.0"
}
// getBuildEventInfo determines build event type and value based on available Drone environment variables
func getBuildEventInfo() (string, string) {
// Use DRONE_BUILD_EVENT as primary source (used in existing scripts)
buildEvent := os.Getenv("DRONE_BUILD_EVENT")
switch buildEvent {
case "tag":
// TAG build - use DRONE_TAG as value
if droneTag := os.Getenv("DRONE_TAG"); droneTag != "" {
return "tag", droneTag
}
return "tag", ""
case "pull_request":
// PR build - use source branch if available
if sourceBranch := os.Getenv("DRONE_SOURCE_BRANCH"); sourceBranch != "" {
return "pull_request", sourceBranch
}
return "pull_request", ""
case "push":
// Push to branch - try to get branch info
if commitBranch := os.Getenv("DRONE_COMMIT_BRANCH"); commitBranch != "" {
return "branch", commitBranch
}
return "push", ""
default:
// Fallback: Check for tag via DRONE_TAG even if DRONE_BUILD_EVENT not set
if droneTag := os.Getenv("DRONE_TAG"); droneTag != "" {
return "tag", droneTag
}
// Fallback: Check for commit via DRONE_COMMIT_SHA
if commitSha := os.Getenv("DRONE_COMMIT_SHA"); commitSha != "" {
return "commit", commitSha
}
}
// Unknown build type
return "", ""
}
// getWorkspaceDirectory returns the directory to analyze (DRONE_WORKSPACE preferred, current dir as fallback)
func getWorkspaceDirectory() (string, error) {
// 1. Use DRONE_WORKSPACE if set (where repository gets cloned)
if workspace := os.Getenv("DRONE_WORKSPACE"); workspace != "" {
slog.Debug("Using DRONE_WORKSPACE for analysis", "directory", workspace)
return workspace, nil
}
// 2. Fallback to current working directory
workdir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("cannot get working directory: %v", err)
}
slog.Debug("Using current directory for analysis", "directory", workdir)
return workdir, nil
}
func main() {
// Ensure temp directory cleanup happens regardless of execution path
defer cleanupTempDir()
// Run git clone first (core functionality - can fail the step)
if err := runGitClone(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
cleanupTempDir() // Manual cleanup before exit
os.Exit(1) // Core git functionality failure - should fail step
}
// Git clone succeeded - now attempt analytics (optional)
// Get workspace directory for analysis
workdir, err := getWorkspaceDirectory()
if err != nil {
slog.Warn("Cannot get workspace directory for analytics, skipping metrics collection", "error", err)
return // Analytics failure - don't fail the step, just skip
}
// Collect code metrics and write complete build tool file
// Note: Analytics failures should not fail the step
if err := tryCollectAndWriteMetrics(workdir); err != nil {
slog.Warn("Metrics collection failed but continuing (analytics only)", "error", err)
// Continue - don't fail the step for analytics issues
}
}