< Summary - go-semantic-release Coverage

Line coverage
92%
Covered lines: 370
Uncovered lines: 30
Coverable lines: 400
Total lines: 604
Line coverage: 92.5%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Name0%00100%
VerifyConditions0%00100%
Publish0%00100%
AddChannel0%0084.62%
Success0%0090%
Fail0%00100%
setHeaders0%00100%
createGHRelease0%0088.46%
getReleaseByTag0%00100%
uploadAssetGlob0%00100%
uploadAsset0%0080%
getPRsForCommit0%00100%
commentOnIssue0%0086.36%
addLabelsToIssue0%0090.32%
findFailureIssue0%00100%
createIssue0%0089.29%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/github/plugin.go

#LineLine coverage
 1package github
 2
 3import (
 4  "bytes"
 5  "context"
 6  "encoding/json"
 7  "fmt"
 8  "io"
 9  "mime"
 10  "net/http"
 11  neturl "net/url"
 12  "os"
 13  "path/filepath"
 14  "strings"
 15  "time"
 16
 17  "github.com/jedi-knights/go-semantic-release/internal/domain"
 18  "github.com/jedi-knights/go-semantic-release/internal/ports"
 19)
 20
 21// Compile-time interface compliance checks.
 22var (
 23  _ ports.Plugin                 = (*Plugin)(nil)
 24  _ ports.VerifyConditionsPlugin = (*Plugin)(nil)
 25  _ ports.PublishPlugin          = (*Plugin)(nil)
 26  _ ports.AddChannelPlugin       = (*Plugin)(nil)
 27  _ ports.SuccessPlugin          = (*Plugin)(nil)
 28  _ ports.FailPlugin             = (*Plugin)(nil)
 29)
 30
 31// PluginConfig holds configuration for the GitHub plugin.
 32type PluginConfig struct {
 33  Owner                  string               `mapstructure:"owner"`
 34  Repo                   string               `mapstructure:"repo"`
 35  Token                  string               `mapstructure:"token"`
 36  APIURL                 string               `mapstructure:"api_url"`
 37  Assets                 []domain.GitHubAsset `mapstructure:"assets"`
 38  DraftRelease           bool                 `mapstructure:"draft_release"`
 39  DiscussionCategoryName string               `mapstructure:"discussion_category_name"`
 40  SuccessComment         string               `mapstructure:"success_comment"`
 41  FailComment            string               `mapstructure:"fail_comment"`
 42  ReleasedLabels         []string             `mapstructure:"released_labels"`
 43  FailLabels             []string             `mapstructure:"fail_labels"`
 44}
 45
 46// Plugin implements multiple lifecycle interfaces for GitHub integration.
 47type Plugin struct {
 48  config PluginConfig
 49  client *http.Client
 50  logger ports.Logger
 51}
 52
 53// NewPlugin creates a GitHub lifecycle plugin.
 54func NewPlugin(cfg PluginConfig, logger ports.Logger) *Plugin {
 55  if cfg.APIURL == "" {
 56    cfg.APIURL = "https://api.github.com"
 57  }
 58  if cfg.Token == "" {
 59    cfg.Token = resolveToken()
 60  }
 61  if cfg.SuccessComment == "" {
 62    cfg.SuccessComment = "🎉 This issue has been resolved in version {{.Version}} 🎉\n\nThe release is available on [Git
 63  }
 64  if cfg.FailComment == "" {
 65    cfg.FailComment = "The release from branch `{{.Branch}}` has failed.\n\nError: {{.Error}}"
 66  }
 67  if len(cfg.ReleasedLabels) == 0 {
 68    cfg.ReleasedLabels = []string{"released"}
 69  }
 70  if len(cfg.FailLabels) == 0 {
 71    cfg.FailLabels = []string{"semantic-release"}
 72  }
 73  return &Plugin{
 74    config: cfg,
 75    client: &http.Client{Timeout: 30 * time.Second},
 76    logger: logger,
 77  }
 78}
 79
 80func resolveToken() string {
 81  for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "SEMANTIC_RELEASE_GITHUB_TOKEN"} {
 82    if v := os.Getenv(key); v != "" {
 83      return v
 84    }
 85  }
 86  return ""
 87}
 88
 289func (p *Plugin) Name() string { return "github" }
 90
 91// VerifyConditions checks that GitHub credentials and config are valid.
 992func (p *Plugin) VerifyConditions(ctx context.Context, rc *domain.ReleaseContext) error {
 193  if p.config.Token == "" {
 194    return fmt.Errorf("GitHub token not found (set GH_TOKEN, GITHUB_TOKEN, or SEMANTIC_RELEASE_GITHUB_TOKEN)")
 195  }
 96
 897  owner, repo := p.config.Owner, p.config.Repo
 198  if owner == "" || repo == "" {
 199    return fmt.Errorf("GitHub owner and repo must be configured")
 1100  }
 101
 102  // Verify token is valid with a lightweight API call.
 7103  url := fmt.Sprintf("%s/repos/%s/%s", p.config.APIURL, neturl.PathEscape(owner), neturl.PathEscape(repo))
 7104  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 1105  if err != nil {
 1106    return fmt.Errorf("creating request: %w", err)
 1107  }
 6108  p.setHeaders(req)
 6109
 6110  resp, err := p.client.Do(req)
 1111  if err != nil {
 1112    return fmt.Errorf("verifying GitHub access: %w", err)
 1113  }
 5114  defer func() { _ = resp.Body.Close() }()
 115
 2116  if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
 2117    return fmt.Errorf("GitHub token is invalid or lacks permissions (HTTP %d)", resp.StatusCode)
 2118  }
 1119  if resp.StatusCode != http.StatusOK {
 1120    return fmt.Errorf("GitHub API returned HTTP %d for repo verification", resp.StatusCode)
 1121  }
 2122  _, _ = io.Copy(io.Discard, resp.Body)
 2123  return nil
 124}
 125
 126// Publish creates a GitHub release, optionally uploads assets.
 4127func (p *Plugin) Publish(ctx context.Context, rc *domain.ReleaseContext) (*domain.ProjectReleaseResult, error) {
 1128  if rc.CurrentProject == nil {
 1129    return nil, nil
 1130  }
 131
 3132  tagName := rc.TagName
 3133  releaseName := tagName
 3134  if rc.CurrentProject.Project.Name != "" {
 3135    releaseName = fmt.Sprintf("%s %s", rc.CurrentProject.Project.Name, rc.CurrentProject.NextVersion.String())
 3136  }
 137
 3138  isPrerelease := rc.BranchPolicy != nil && rc.BranchPolicy.Prerelease
 3139
 3140  reqBody := ghCreateReleaseRequest{
 3141    TagName:                tagName,
 3142    Name:                   releaseName,
 3143    Body:                   rc.Notes,
 3144    Prerelease:             isPrerelease,
 3145    Draft:                  p.config.DraftRelease,
 3146    DiscussionCategoryName: p.config.DiscussionCategoryName,
 3147  }
 3148
 3149  releaseResp, err := p.createGHRelease(ctx, reqBody)
 1150  if err != nil {
 1151    return nil, err
 1152  }
 153
 154  // Upload assets.
 1155  for _, asset := range p.config.Assets {
 1156    if err := p.uploadAssetGlob(ctx, releaseResp.UploadURL, asset); err != nil {
 1157      p.logger.Warn("failed to upload asset", "pattern", asset.Path, "error", err)
 1158    }
 159  }
 160
 2161  return &domain.ProjectReleaseResult{
 2162    Project:    rc.CurrentProject.Project,
 2163    Version:    rc.CurrentProject.NextVersion,
 2164    TagName:    tagName,
 2165    Published:  true,
 2166    PublishURL: releaseResp.HTMLURL,
 2167    Changelog:  rc.Notes,
 2168  }, nil
 169}
 170
 171// AddChannel updates a release's prerelease status based on the channel.
 6172func (p *Plugin) AddChannel(ctx context.Context, rc *domain.ReleaseContext) error {
 1173  if rc.TagName == "" {
 1174    return nil
 1175  }
 176
 177  // Find existing release by tag.
 5178  release, err := p.getReleaseByTag(ctx, rc.TagName)
 1179  if err != nil {
 1180    return fmt.Errorf("finding release for tag %s: %w", rc.TagName, err)
 1181  }
 1182  if release == nil {
 1183    return nil
 1184  }
 185
 3186  isPrerelease := rc.BranchPolicy != nil && rc.BranchPolicy.Prerelease
 3187
 3188  // Update the prerelease field.
 3189  updateBody := map[string]any{
 3190    "prerelease": isPrerelease,
 3191  }
 3192  jsonData, err := json.Marshal(updateBody)
 0193  if err != nil {
 0194    return fmt.Errorf("marshaling release update: %w", err)
 0195  }
 196
 3197  url := fmt.Sprintf("%s/repos/%s/%s/releases/%d", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape
 3198  req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(jsonData))
 0199  if err != nil {
 0200    return err
 0201  }
 3202  p.setHeaders(req)
 3203
 3204  resp, err := p.client.Do(req)
 1205  if err != nil {
 1206    return fmt.Errorf("updating release: %w", err)
 1207  }
 2208  defer func() { _ = resp.Body.Close() }()
 209
 1210  if resp.StatusCode != http.StatusOK {
 1211    body, _ := io.ReadAll(resp.Body)
 1212    return fmt.Errorf("updating release failed (%d): %s", resp.StatusCode, string(body))
 1213  }
 214
 1215  p.logger.Info("updated release channel", "tag", rc.TagName, "prerelease", isPrerelease)
 1216  return nil
 217}
 218
 219// Success comments on merged PRs and resolved issues.
 4220func (p *Plugin) Success(ctx context.Context, rc *domain.ReleaseContext) error {
 1221  if rc.CurrentProject == nil || rc.Result == nil {
 1222    return nil
 1223  }
 224
 225  // Find the publish URL for this project.
 3226  releaseURL := ""
 3227  for i := range rc.Result.Projects {
 3228    if rc.Result.Projects[i].Project.Name == rc.CurrentProject.Project.Name {
 3229      releaseURL = rc.Result.Projects[i].PublishURL
 3230      break
 231    }
 232  }
 233
 3234  comment := strings.NewReplacer(
 3235    "{{.Version}}", rc.CurrentProject.NextVersion.String(),
 3236    "{{.ReleaseURL}}", releaseURL,
 3237    "{{.Branch}}", rc.Branch,
 3238    "{{.TagName}}", rc.TagName,
 3239  ).Replace(p.config.SuccessComment)
 3240
 3241  // Comment on commits' associated PRs.
 3242  for i := range rc.CurrentProject.Commits {
 3243    prs, err := p.getPRsForCommit(ctx, rc.CurrentProject.Commits[i].Hash)
 1244    if err != nil {
 1245      p.logger.Debug("failed to get PRs for commit", "hash", rc.CurrentProject.Commits[i].Hash, "error", err)
 1246      continue
 247    }
 2248    for _, pr := range prs {
 1249      if err := p.commentOnIssue(ctx, pr.Number, comment); err != nil {
 1250        p.logger.Debug("failed to comment on PR", "number", pr.Number, "error", err)
 1251      }
 0252      if err := p.addLabelsToIssue(ctx, pr.Number, p.config.ReleasedLabels); err != nil {
 0253        p.logger.Warn("failed to add labels to PR", "number", pr.Number, "error", err)
 0254      }
 255    }
 256  }
 257
 3258  return nil
 259}
 260
 261// Fail opens or updates a GitHub issue documenting the failure.
 6262func (p *Plugin) Fail(ctx context.Context, rc *domain.ReleaseContext) error {
 1263  if rc.Error == nil {
 1264    return nil
 1265  }
 266
 5267  body := strings.NewReplacer(
 5268    "{{.Branch}}", rc.Branch,
 5269    "{{.Error}}", rc.Error.Error(),
 5270  ).Replace(p.config.FailComment)
 5271
 5272  title := "The automated release is failing"
 5273
 5274  // Check for existing failure issue.
 5275  existing, err := p.findFailureIssue(ctx, title)
 1276  if err != nil {
 1277    p.logger.Debug("failed to search for existing failure issue", "error", err)
 1278  }
 279
 1280  if existing != nil {
 1281    return p.commentOnIssue(ctx, existing.Number, body)
 1282  }
 283
 4284  return p.createIssue(ctx, title, body, p.config.FailLabels)
 285}
 286
 287// --- Helper methods ---
 288
 289type ghCreateReleaseRequest struct {
 290  TagName                string `json:"tag_name"`
 291  Name                   string `json:"name"`
 292  Body                   string `json:"body"`
 293  Prerelease             bool   `json:"prerelease"`
 294  Draft                  bool   `json:"draft"`
 295  DiscussionCategoryName string `json:"discussion_category_name,omitempty"`
 296}
 297
 298type ghRelease struct {
 299  ID        int    `json:"id"`
 300  HTMLURL   string `json:"html_url"`
 301  TagName   string `json:"tag_name"`
 302  UploadURL string `json:"upload_url"`
 303}
 304
 305type ghPR struct {
 306  Number int `json:"number"`
 307}
 308
 309type ghIssue struct {
 310  Number int    `json:"number"`
 311  Title  string `json:"title"`
 312  State  string `json:"state"`
 313}
 314
 51315func (p *Plugin) setHeaders(req *http.Request) {
 51316  req.Header.Set("Authorization", "token "+p.config.Token)
 51317  req.Header.Set("Content-Type", "application/json")
 51318  req.Header.Set("Accept", "application/vnd.github+json")
 51319}
 320
 6321func (p *Plugin) createGHRelease(ctx context.Context, reqBody ghCreateReleaseRequest) (*ghRelease, error) {
 6322  jsonData, err := json.Marshal(reqBody)
 0323  if err != nil {
 0324    return nil, fmt.Errorf("marshaling release request: %w", err)
 0325  }
 326
 6327  url := fmt.Sprintf("%s/repos/%s/%s/releases", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape(p.
 6328  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 1329  if err != nil {
 1330    return nil, fmt.Errorf("creating request: %w", err)
 1331  }
 5332  p.setHeaders(req)
 5333
 5334  resp, err := p.client.Do(req)
 1335  if err != nil {
 1336    return nil, fmt.Errorf("publishing release: %w", err)
 1337  }
 4338  defer func() { _ = resp.Body.Close() }()
 339
 1340  if resp.StatusCode != http.StatusCreated {
 1341    respBody, _ := io.ReadAll(resp.Body)
 1342    return nil, fmt.Errorf("github create release failed (%d): %s", resp.StatusCode, string(respBody))
 1343  }
 344
 3345  var release ghRelease
 1346  if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 1347    return nil, fmt.Errorf("decoding release response: %w", err)
 1348  }
 2349  return &release, nil
 350}
 351
 9352func (p *Plugin) getReleaseByTag(ctx context.Context, tag string) (*ghRelease, error) {
 9353  url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathE
 9354  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 1355  if err != nil {
 1356    return nil, err
 1357  }
 8358  p.setHeaders(req)
 8359
 8360  resp, err := p.client.Do(req)
 1361  if err != nil {
 1362    return nil, err
 1363  }
 7364  defer func() { _ = resp.Body.Close() }()
 365
 1366  if resp.StatusCode == http.StatusNotFound {
 1367    return nil, nil
 1368  }
 2369  if resp.StatusCode != http.StatusOK {
 2370    return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
 2371  }
 372
 4373  var release ghRelease
 1374  if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 1375    return nil, err
 1376  }
 3377  return &release, nil
 378}
 379
 6380func (p *Plugin) uploadAssetGlob(ctx context.Context, uploadURL string, asset domain.GitHubAsset) error {
 6381  matches, err := filepath.Glob(asset.Path)
 1382  if err != nil {
 1383    return fmt.Errorf("globbing %s: %w", asset.Path, err)
 1384  }
 385
 5386  for _, path := range matches {
 2387    if err := p.uploadAsset(ctx, uploadURL, path, asset.Label); err != nil {
 2388      return err
 2389    }
 390  }
 3391  return nil
 392}
 393
 12394func (p *Plugin) uploadAsset(ctx context.Context, uploadURL, filePath, label string) error {
 12395  file, err := os.Open(filePath)
 1396  if err != nil {
 1397    return fmt.Errorf("opening %s: %w", filePath, err)
 1398  }
 11399  defer func() { _ = file.Close() }()
 400
 11401  stat, err := file.Stat()
 0402  if err != nil {
 0403    return fmt.Errorf("stat %s: %w", filePath, err)
 0404  }
 405
 11406  name := filepath.Base(filePath)
 11407  contentType := mime.TypeByExtension(filepath.Ext(filePath))
 1408  if contentType == "" {
 1409    contentType = "application/octet-stream"
 1410  }
 411
 412  // Strip the URI template suffix (e.g. "{?name,label}") that GitHub appends to upload_url.
 11413  base := uploadURL
 0414  if i := strings.Index(base, "{"); i >= 0 {
 0415    base = base[:i]
 0416  }
 11417  q := neturl.Values{}
 11418  q.Set("name", name)
 2419  if label != "" {
 2420    q.Set("label", label)
 2421  }
 11422  fullUploadURL := base + "?" + q.Encode()
 11423
 11424  req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUploadURL, file)
 0425  if err != nil {
 0426    return fmt.Errorf("creating upload request: %w", err)
 0427  }
 428
 11429  req.Header.Set("Authorization", "token "+p.config.Token)
 11430  req.Header.Set("Content-Type", contentType)
 11431  req.ContentLength = stat.Size()
 11432
 11433  resp, err := p.client.Do(req)
 1434  if err != nil {
 1435    return fmt.Errorf("uploading asset %s: %w", name, err)
 1436  }
 10437  defer func() { _ = resp.Body.Close() }()
 438
 3439  if resp.StatusCode != http.StatusCreated {
 3440    body, _ := io.ReadAll(resp.Body)
 3441    return fmt.Errorf("upload asset failed (%d): %s", resp.StatusCode, string(body))
 3442  }
 443
 7444  p.logger.Info("uploaded asset", "file", name)
 7445  return nil
 446}
 447
 7448func (p *Plugin) getPRsForCommit(ctx context.Context, sha string) ([]ghPR, error) {
 7449  url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/pulls", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathE
 7450  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 1451  if err != nil {
 1452    return nil, err
 1453  }
 6454  p.setHeaders(req)
 6455
 6456  resp, err := p.client.Do(req)
 2457  if err != nil {
 2458    return nil, err
 2459  }
 4460  defer func() { _ = resp.Body.Close() }()
 461
 1462  if resp.StatusCode != http.StatusOK {
 1463    return nil, nil
 1464  }
 465
 3466  var prs []ghPR
 1467  if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
 1468    return nil, err
 1469  }
 2470  return prs, nil
 471}
 472
 6473func (p *Plugin) commentOnIssue(ctx context.Context, number int, body string) error {
 6474  payload := map[string]string{"body": body}
 6475  jsonData, err := json.Marshal(payload)
 0476  if err != nil {
 0477    return fmt.Errorf("marshaling comment: %w", err)
 0478  }
 479
 6480  url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.Pat
 6481  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 1482  if err != nil {
 1483    return err
 1484  }
 5485  p.setHeaders(req)
 5486
 5487  resp, err := p.client.Do(req)
 1488  if err != nil {
 1489    return err
 1490  }
 4491  defer func() { _ = resp.Body.Close() }()
 492
 2493  if resp.StatusCode != http.StatusCreated {
 2494    return fmt.Errorf("comment failed (%d)", resp.StatusCode)
 2495  }
 2496  return nil
 497}
 498
 6499func (p *Plugin) addLabelsToIssue(ctx context.Context, number int, labels []string) error {
 1500  if len(labels) == 0 {
 1501    return nil
 1502  }
 5503  payload := map[string][]string{"labels": labels}
 5504  jsonData, err := json.Marshal(payload)
 0505  if err != nil {
 0506    return fmt.Errorf("marshaling labels: %w", err)
 0507  }
 508
 5509  url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/labels",
 5510    p.config.APIURL,
 5511    neturl.PathEscape(p.config.Owner),
 5512    neturl.PathEscape(p.config.Repo),
 5513    number)
 5514  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 1515  if err != nil {
 1516    return fmt.Errorf("creating request: %w", err)
 1517  }
 4518  p.setHeaders(req)
 4519
 4520  resp, err := p.client.Do(req)
 1521  if err != nil {
 1522    return fmt.Errorf("adding labels: %w", err)
 1523  }
 3524  defer func() { _ = resp.Body.Close() }()
 1525  if resp.StatusCode != http.StatusOK {
 1526    body, _ := io.ReadAll(resp.Body)
 1527    return fmt.Errorf("adding labels failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
 1528  }
 2529  _, _ = io.Copy(io.Discard, resp.Body)
 2530  return nil
 531}
 532
 9533func (p *Plugin) findFailureIssue(ctx context.Context, title string) (*ghIssue, error) {
 9534  q := neturl.Values{
 9535    "state":  {"open"},
 9536    "labels": {strings.Join(p.config.FailLabels, ",")},
 9537  }
 9538  url := fmt.Sprintf("%s/repos/%s/%s/issues?%s",
 9539    p.config.APIURL,
 9540    neturl.PathEscape(p.config.Owner),
 9541    neturl.PathEscape(p.config.Repo),
 9542    q.Encode())
 9543  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
 1544  if err != nil {
 1545    return nil, err
 1546  }
 8547  p.setHeaders(req)
 8548
 8549  resp, err := p.client.Do(req)
 1550  if err != nil {
 1551    return nil, err
 1552  }
 7553  defer func() { _ = resp.Body.Close() }()
 554
 2555  if resp.StatusCode != http.StatusOK {
 2556    body, _ := io.ReadAll(resp.Body)
 2557    return nil, fmt.Errorf("listing issues failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
 2558  }
 559
 5560  var issues []ghIssue
 1561  if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
 1562    return nil, err
 1563  }
 564
 1565  for _, issue := range issues {
 1566    if issue.Title == title {
 1567      return &issue, nil
 1568    }
 569  }
 3570  return nil, nil
 571}
 572
 7573func (p *Plugin) createIssue(ctx context.Context, title, body string, labels []string) error {
 7574  payload := map[string]any{
 7575    "title":  title,
 7576    "body":   body,
 7577    "labels": labels,
 7578  }
 7579  jsonData, err := json.Marshal(payload)
 0580  if err != nil {
 0581    return fmt.Errorf("marshaling issue: %w", err)
 0582  }
 583
 7584  url := fmt.Sprintf("%s/repos/%s/%s/issues", p.config.APIURL, neturl.PathEscape(p.config.Owner), neturl.PathEscape(p.co
 7585  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
 1586  if err != nil {
 1587    return err
 1588  }
 6589  p.setHeaders(req)
 6590
 6591  resp, err := p.client.Do(req)
 1592  if err != nil {
 1593    return err
 1594  }
 5595  defer func() { _ = resp.Body.Close() }()
 596
 2597  if resp.StatusCode != http.StatusCreated {
 2598    respBody, _ := io.ReadAll(resp.Body)
 2599    return fmt.Errorf("create issue failed (%d): %s", resp.StatusCode, string(respBody))
 2600  }
 601
 3602  p.logger.Info("created failure issue", "title", title)
 3603  return nil
 604}