| | | 1 | | package plugins |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "bytes" |
| | | 5 | | "context" |
| | | 6 | | "encoding/json" |
| | | 7 | | "fmt" |
| | | 8 | | "os/exec" |
| | | 9 | | "strings" |
| | | 10 | | |
| | | 11 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 12 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 13 | | ) |
| | | 14 | | |
| | | 15 | | // Compile-time interface compliance checks. |
| | | 16 | | var ( |
| | | 17 | | _ ports.Plugin = (*ExternalPlugin)(nil) |
| | | 18 | | _ ports.VerifyConditionsPlugin = (*ExternalPlugin)(nil) |
| | | 19 | | _ ports.AnalyzeCommitsPlugin = (*ExternalPlugin)(nil) |
| | | 20 | | _ ports.VerifyReleasePlugin = (*ExternalPlugin)(nil) |
| | | 21 | | _ ports.GenerateNotesPlugin = (*ExternalPlugin)(nil) |
| | | 22 | | _ ports.PreparePlugin = (*ExternalPlugin)(nil) |
| | | 23 | | _ ports.PublishPlugin = (*ExternalPlugin)(nil) |
| | | 24 | | _ ports.AddChannelPlugin = (*ExternalPlugin)(nil) |
| | | 25 | | _ ports.SuccessPlugin = (*ExternalPlugin)(nil) |
| | | 26 | | _ ports.FailPlugin = (*ExternalPlugin)(nil) |
| | | 27 | | ) |
| | | 28 | | |
| | | 29 | | // ExternalPlugin wraps an external executable as a lifecycle plugin. |
| | | 30 | | // Communication uses JSON over stdin/stdout. |
| | | 31 | | type ExternalPlugin struct { |
| | | 32 | | name string |
| | | 33 | | executable string |
| | | 34 | | } |
| | | 35 | | |
| | | 36 | | // NewExternalPlugin creates an external plugin adapter. |
| | | 37 | | func NewExternalPlugin(name, executable string) *ExternalPlugin { |
| | | 38 | | return &ExternalPlugin{name: name, executable: executable} |
| | | 39 | | } |
| | | 40 | | |
| | 1 | 41 | | func (p *ExternalPlugin) Name() string { return p.name } |
| | | 42 | | |
| | | 43 | | // VerifyConditions calls the external plugin's verifyConditions step. |
| | 5 | 44 | | func (p *ExternalPlugin) VerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 5 | 45 | | _, err := p.invoke(ctx, string(domain.StepVerifyConditions), rc) |
| | 5 | 46 | | return err |
| | 5 | 47 | | } |
| | | 48 | | |
| | | 49 | | // AnalyzeCommits calls the external plugin's analyzeCommits step. |
| | 3 | 50 | | func (p *ExternalPlugin) AnalyzeCommits(ctx context.Context, rc *domain.ReleaseContext) (domain.ReleaseType, error) { |
| | 3 | 51 | | resp, err := p.invoke(ctx, string(domain.StepAnalyzeCommits), rc) |
| | 0 | 52 | | if err != nil { |
| | 0 | 53 | | return domain.ReleaseNone, err |
| | 0 | 54 | | } |
| | | 55 | | |
| | 3 | 56 | | switch strings.ToLower(resp.ReleaseType) { |
| | 1 | 57 | | case "major": |
| | 1 | 58 | | return domain.ReleaseMajor, nil |
| | 1 | 59 | | case "minor": |
| | 1 | 60 | | return domain.ReleaseMinor, nil |
| | 0 | 61 | | case "patch": |
| | 0 | 62 | | return domain.ReleasePatch, nil |
| | 1 | 63 | | default: |
| | 1 | 64 | | return domain.ReleaseNone, nil |
| | | 65 | | } |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | // VerifyRelease calls the external plugin's verifyRelease step. |
| | 2 | 69 | | func (p *ExternalPlugin) VerifyRelease(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 2 | 70 | | _, err := p.invoke(ctx, string(domain.StepVerifyRelease), rc) |
| | 2 | 71 | | return err |
| | 2 | 72 | | } |
| | | 73 | | |
| | | 74 | | // GenerateNotes calls the external plugin's generateNotes step. |
| | 1 | 75 | | func (p *ExternalPlugin) GenerateNotes(ctx context.Context, rc *domain.ReleaseContext) (string, error) { |
| | 1 | 76 | | resp, err := p.invoke(ctx, string(domain.StepGenerateNotes), rc) |
| | 0 | 77 | | if err != nil { |
| | 0 | 78 | | return "", err |
| | 0 | 79 | | } |
| | 1 | 80 | | return resp.Notes, nil |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | // Prepare calls the external plugin's prepare step. |
| | 1 | 84 | | func (p *ExternalPlugin) Prepare(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 1 | 85 | | _, err := p.invoke(ctx, string(domain.StepPrepare), rc) |
| | 1 | 86 | | return err |
| | 1 | 87 | | } |
| | | 88 | | |
| | | 89 | | // Publish calls the external plugin's publish step. |
| | 1 | 90 | | func (p *ExternalPlugin) Publish(ctx context.Context, rc *domain.ReleaseContext) (*domain.ProjectReleaseResult, error) { |
| | 1 | 91 | | _, err := p.invoke(ctx, string(domain.StepPublish), rc) |
| | 0 | 92 | | if err != nil { |
| | 0 | 93 | | return nil, err |
| | 0 | 94 | | } |
| | 1 | 95 | | return nil, nil |
| | | 96 | | } |
| | | 97 | | |
| | | 98 | | // AddChannel calls the external plugin's addChannel step. |
| | 1 | 99 | | func (p *ExternalPlugin) AddChannel(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 1 | 100 | | _, err := p.invoke(ctx, string(domain.StepAddChannel), rc) |
| | 1 | 101 | | return err |
| | 1 | 102 | | } |
| | | 103 | | |
| | | 104 | | // Success calls the external plugin's success step. |
| | 1 | 105 | | func (p *ExternalPlugin) Success(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 1 | 106 | | _, err := p.invoke(ctx, string(domain.StepSuccess), rc) |
| | 1 | 107 | | return err |
| | 1 | 108 | | } |
| | | 109 | | |
| | | 110 | | // Fail calls the external plugin's fail step. |
| | 1 | 111 | | func (p *ExternalPlugin) Fail(ctx context.Context, rc *domain.ReleaseContext) error { |
| | 1 | 112 | | _, err := p.invoke(ctx, string(domain.StepFail), rc) |
| | 1 | 113 | | return err |
| | 1 | 114 | | } |
| | | 115 | | |
| | 16 | 116 | | func (p *ExternalPlugin) invoke(ctx context.Context, step string, rc *domain.ReleaseContext) (*ExternalPluginResponse, e |
| | 16 | 117 | | request := ExternalPluginRequest{ |
| | 16 | 118 | | Step: step, |
| | 16 | 119 | | Context: toExternalContext(rc), |
| | 16 | 120 | | } |
| | 16 | 121 | | |
| | 16 | 122 | | inputData, err := json.Marshal(request) |
| | 0 | 123 | | if err != nil { |
| | 0 | 124 | | return nil, fmt.Errorf("marshaling plugin request: %w", err) |
| | 0 | 125 | | } |
| | | 126 | | |
| | 16 | 127 | | cmd := exec.CommandContext(ctx, p.executable, "--step", step) |
| | 16 | 128 | | cmd.Stdin = bytes.NewReader(inputData) |
| | 16 | 129 | | |
| | 16 | 130 | | var stdout, stderr bytes.Buffer |
| | 16 | 131 | | cmd.Stdout = &stdout |
| | 16 | 132 | | cmd.Stderr = &stderr |
| | 16 | 133 | | |
| | 3 | 134 | | if err := cmd.Run(); err != nil { |
| | 3 | 135 | | errMsg := strings.TrimSpace(stderr.String()) |
| | 1 | 136 | | if errMsg == "" { |
| | 1 | 137 | | errMsg = err.Error() |
| | 1 | 138 | | } |
| | 3 | 139 | | return nil, fmt.Errorf("external plugin %q step %s failed: %s", p.name, step, errMsg) |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | // If no output, the plugin doesn't implement this step — that's OK. |
| | 7 | 143 | | if stdout.Len() == 0 { |
| | 7 | 144 | | return &ExternalPluginResponse{}, nil |
| | 7 | 145 | | } |
| | | 146 | | |
| | 6 | 147 | | var resp ExternalPluginResponse |
| | 1 | 148 | | if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { |
| | 1 | 149 | | return nil, fmt.Errorf("parsing plugin %q response: %w", p.name, err) |
| | 1 | 150 | | } |
| | | 151 | | |
| | 1 | 152 | | if resp.Error != "" { |
| | 1 | 153 | | return nil, fmt.Errorf("external plugin %q: %s", p.name, resp.Error) |
| | 1 | 154 | | } |
| | | 155 | | |
| | 4 | 156 | | return &resp, nil |
| | | 157 | | } |