@harness-engineering/core 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12574,6 +12574,29 @@ function labelsForStatus(status, config) {
12574
12574
  }
12575
12575
  return [...base];
12576
12576
  }
12577
+ var RETRY_DEFAULTS = { maxRetries: 5, baseDelayMs: 1e3 };
12578
+ function sleep(ms) {
12579
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
12580
+ }
12581
+ async function fetchWithRetry(fetchFn, input, init, opts = RETRY_DEFAULTS) {
12582
+ let lastResponse;
12583
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
12584
+ const response = await fetchFn(input, init);
12585
+ if (response.status !== 403 && response.status !== 429) return response;
12586
+ lastResponse = response;
12587
+ if (attempt === opts.maxRetries) break;
12588
+ const retryAfter = response.headers.get("Retry-After");
12589
+ let delayMs;
12590
+ if (retryAfter) {
12591
+ const seconds = parseInt(retryAfter, 10);
12592
+ delayMs = isNaN(seconds) ? opts.baseDelayMs : seconds * 1e3;
12593
+ } else {
12594
+ delayMs = opts.baseDelayMs * Math.pow(2, attempt) + Math.random() * 500;
12595
+ }
12596
+ await sleep(delayMs);
12597
+ }
12598
+ return lastResponse;
12599
+ }
12577
12600
  var GitHubIssuesSyncAdapter = class {
12578
12601
  token;
12579
12602
  config;
@@ -12581,11 +12604,18 @@ var GitHubIssuesSyncAdapter = class {
12581
12604
  apiBase;
12582
12605
  owner;
12583
12606
  repo;
12607
+ retryOpts;
12608
+ /** Cached GitHub milestone name -> ID mapping */
12609
+ milestoneCache = null;
12584
12610
  constructor(options) {
12585
12611
  this.token = options.token;
12586
12612
  this.config = options.config;
12587
12613
  this.fetchFn = options.fetchFn ?? globalThis.fetch;
12588
12614
  this.apiBase = options.apiBase ?? "https://api.github.com";
12615
+ this.retryOpts = {
12616
+ maxRetries: options.maxRetries ?? RETRY_DEFAULTS.maxRetries,
12617
+ baseDelayMs: options.baseDelayMs ?? RETRY_DEFAULTS.baseDelayMs
12618
+ };
12589
12619
  const repoParts = (options.config.repo ?? "").split("/");
12590
12620
  if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
12591
12621
  throw new Error(`Invalid repo format: "${options.config.repo}". Expected "owner/repo".`);
@@ -12593,6 +12623,43 @@ var GitHubIssuesSyncAdapter = class {
12593
12623
  this.owner = repoParts[0];
12594
12624
  this.repo = repoParts[1];
12595
12625
  }
12626
+ /**
12627
+ * Fetch all GitHub milestones and build the name -> ID cache.
12628
+ */
12629
+ async loadMilestones() {
12630
+ if (this.milestoneCache) return this.milestoneCache;
12631
+ this.milestoneCache = /* @__PURE__ */ new Map();
12632
+ const response = await fetchWithRetry(
12633
+ this.fetchFn,
12634
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/milestones?state=all&per_page=100`,
12635
+ { method: "GET", headers: this.headers() },
12636
+ this.retryOpts
12637
+ );
12638
+ if (response.ok) {
12639
+ const data = await response.json();
12640
+ for (const m of data) {
12641
+ this.milestoneCache.set(m.title, m.number);
12642
+ }
12643
+ }
12644
+ return this.milestoneCache;
12645
+ }
12646
+ /**
12647
+ * Get or create a GitHub milestone by name. Returns the milestone number.
12648
+ */
12649
+ async ensureMilestone(name) {
12650
+ const cache = await this.loadMilestones();
12651
+ if (cache.has(name)) return cache.get(name);
12652
+ const response = await fetchWithRetry(
12653
+ this.fetchFn,
12654
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/milestones`,
12655
+ { method: "POST", headers: this.headers(), body: JSON.stringify({ title: name }) },
12656
+ this.retryOpts
12657
+ );
12658
+ if (!response.ok) return null;
12659
+ const data = await response.json();
12660
+ cache.set(name, data.number);
12661
+ return data.number;
12662
+ }
12596
12663
  headers() {
12597
12664
  return {
12598
12665
  Authorization: `Bearer ${this.token}`,
@@ -12601,26 +12668,32 @@ var GitHubIssuesSyncAdapter = class {
12601
12668
  "X-GitHub-Api-Version": "2022-11-28"
12602
12669
  };
12603
12670
  }
12671
+ /**
12672
+ * Close an issue if the feature status maps to 'closed'.
12673
+ * GitHub Issues API doesn't accept state on POST — requires a follow-up PATCH.
12674
+ */
12675
+ async closeIfDone(issueNumber, featureStatus) {
12676
+ const externalStatus = this.config.statusMap[featureStatus];
12677
+ if (externalStatus !== "closed") return;
12678
+ await fetchWithRetry(
12679
+ this.fetchFn,
12680
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/issues/${issueNumber}`,
12681
+ { method: "PATCH", headers: this.headers(), body: JSON.stringify({ state: "closed" }) },
12682
+ this.retryOpts
12683
+ );
12684
+ }
12604
12685
  async createTicket(feature, milestone) {
12605
12686
  try {
12606
- const labels = labelsForStatus(feature.status, this.config);
12607
- const body = [
12608
- feature.summary,
12609
- "",
12610
- `**Milestone:** ${milestone}`,
12611
- feature.spec ? `**Spec:** ${feature.spec}` : ""
12612
- ].filter(Boolean).join("\n");
12613
- const response = await this.fetchFn(
12687
+ const labels = [...labelsForStatus(feature.status, this.config), "feature"];
12688
+ const body = [feature.summary, "", feature.spec ? `**Spec:** ${feature.spec}` : ""].filter(Boolean).join("\n");
12689
+ const milestoneId = await this.ensureMilestone(milestone);
12690
+ const issuePayload = { title: feature.name, body, labels };
12691
+ if (milestoneId) issuePayload.milestone = milestoneId;
12692
+ const response = await fetchWithRetry(
12693
+ this.fetchFn,
12614
12694
  `${this.apiBase}/repos/${this.owner}/${this.repo}/issues`,
12615
- {
12616
- method: "POST",
12617
- headers: this.headers(),
12618
- body: JSON.stringify({
12619
- title: feature.name,
12620
- body,
12621
- labels
12622
- })
12623
- }
12695
+ { method: "POST", headers: this.headers(), body: JSON.stringify(issuePayload) },
12696
+ this.retryOpts
12624
12697
  );
12625
12698
  if (!response.ok) {
12626
12699
  const text = await response.text();
@@ -12628,12 +12701,13 @@ var GitHubIssuesSyncAdapter = class {
12628
12701
  }
12629
12702
  const data = await response.json();
12630
12703
  const externalId = buildExternalId(this.owner, this.repo, data.number);
12704
+ await this.closeIfDone(data.number, feature.status);
12631
12705
  return (0, import_types21.Ok)({ externalId, url: data.html_url });
12632
12706
  } catch (error) {
12633
12707
  return (0, import_types21.Err)(error instanceof Error ? error : new Error(String(error)));
12634
12708
  }
12635
12709
  }
12636
- async updateTicket(externalId, changes) {
12710
+ async updateTicket(externalId, changes, milestone) {
12637
12711
  try {
12638
12712
  const parsed = parseExternalId(externalId);
12639
12713
  if (!parsed) return (0, import_types21.Err)(new Error(`Invalid externalId format: "${externalId}"`));
@@ -12646,15 +12720,21 @@ var GitHubIssuesSyncAdapter = class {
12646
12720
  if (changes.status !== void 0) {
12647
12721
  const externalStatus = this.config.statusMap[changes.status];
12648
12722
  patch.state = externalStatus;
12649
- patch.labels = labelsForStatus(changes.status, this.config);
12723
+ patch.labels = [...labelsForStatus(changes.status, this.config), "feature"];
12724
+ }
12725
+ if (milestone) {
12726
+ const milestoneId = await this.ensureMilestone(milestone);
12727
+ if (milestoneId) patch.milestone = milestoneId;
12650
12728
  }
12651
- const response = await this.fetchFn(
12729
+ const response = await fetchWithRetry(
12730
+ this.fetchFn,
12652
12731
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`,
12653
12732
  {
12654
12733
  method: "PATCH",
12655
12734
  headers: this.headers(),
12656
12735
  body: JSON.stringify(patch)
12657
- }
12736
+ },
12737
+ this.retryOpts
12658
12738
  );
12659
12739
  if (!response.ok) {
12660
12740
  const text = await response.text();
@@ -12670,12 +12750,14 @@ var GitHubIssuesSyncAdapter = class {
12670
12750
  try {
12671
12751
  const parsed = parseExternalId(externalId);
12672
12752
  if (!parsed) return (0, import_types21.Err)(new Error(`Invalid externalId format: "${externalId}"`));
12673
- const response = await this.fetchFn(
12753
+ const response = await fetchWithRetry(
12754
+ this.fetchFn,
12674
12755
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`,
12675
12756
  {
12676
12757
  method: "GET",
12677
12758
  headers: this.headers()
12678
- }
12759
+ },
12760
+ this.retryOpts
12679
12761
  );
12680
12762
  if (!response.ok) {
12681
12763
  const text = await response.text();
@@ -12700,12 +12782,14 @@ var GitHubIssuesSyncAdapter = class {
12700
12782
  let page = 1;
12701
12783
  const perPage = 100;
12702
12784
  while (true) {
12703
- const response = await this.fetchFn(
12785
+ const response = await fetchWithRetry(
12786
+ this.fetchFn,
12704
12787
  `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
12705
12788
  {
12706
12789
  method: "GET",
12707
12790
  headers: this.headers()
12708
- }
12791
+ },
12792
+ this.retryOpts
12709
12793
  );
12710
12794
  if (!response.ok) {
12711
12795
  const text = await response.text();
@@ -12734,13 +12818,15 @@ var GitHubIssuesSyncAdapter = class {
12734
12818
  const parsed = parseExternalId(externalId);
12735
12819
  if (!parsed) return (0, import_types21.Err)(new Error(`Invalid externalId format: "${externalId}"`));
12736
12820
  const login = assignee.startsWith("@") ? assignee.slice(1) : assignee;
12737
- const response = await this.fetchFn(
12821
+ const response = await fetchWithRetry(
12822
+ this.fetchFn,
12738
12823
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}/assignees`,
12739
12824
  {
12740
12825
  method: "POST",
12741
12826
  headers: this.headers(),
12742
12827
  body: JSON.stringify({ assignees: [login] })
12743
- }
12828
+ },
12829
+ this.retryOpts
12744
12830
  );
12745
12831
  if (!response.ok) {
12746
12832
  const text = await response.text();
@@ -12771,7 +12857,11 @@ async function syncToExternal(roadmap, adapter, _config) {
12771
12857
  result.errors.push({ featureOrId: feature.name, error: createResult.error });
12772
12858
  }
12773
12859
  } else {
12774
- const updateResult = await adapter.updateTicket(feature.externalId, feature);
12860
+ const updateResult = await adapter.updateTicket(
12861
+ feature.externalId,
12862
+ feature,
12863
+ milestone.name
12864
+ );
12775
12865
  if (updateResult.ok) {
12776
12866
  result.updated.push(feature.externalId);
12777
12867
  } else {
package/dist/index.mjs CHANGED
@@ -10465,6 +10465,29 @@ function labelsForStatus(status, config) {
10465
10465
  }
10466
10466
  return [...base];
10467
10467
  }
10468
+ var RETRY_DEFAULTS = { maxRetries: 5, baseDelayMs: 1e3 };
10469
+ function sleep(ms) {
10470
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
10471
+ }
10472
+ async function fetchWithRetry(fetchFn, input, init, opts = RETRY_DEFAULTS) {
10473
+ let lastResponse;
10474
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
10475
+ const response = await fetchFn(input, init);
10476
+ if (response.status !== 403 && response.status !== 429) return response;
10477
+ lastResponse = response;
10478
+ if (attempt === opts.maxRetries) break;
10479
+ const retryAfter = response.headers.get("Retry-After");
10480
+ let delayMs;
10481
+ if (retryAfter) {
10482
+ const seconds = parseInt(retryAfter, 10);
10483
+ delayMs = isNaN(seconds) ? opts.baseDelayMs : seconds * 1e3;
10484
+ } else {
10485
+ delayMs = opts.baseDelayMs * Math.pow(2, attempt) + Math.random() * 500;
10486
+ }
10487
+ await sleep(delayMs);
10488
+ }
10489
+ return lastResponse;
10490
+ }
10468
10491
  var GitHubIssuesSyncAdapter = class {
10469
10492
  token;
10470
10493
  config;
@@ -10472,11 +10495,18 @@ var GitHubIssuesSyncAdapter = class {
10472
10495
  apiBase;
10473
10496
  owner;
10474
10497
  repo;
10498
+ retryOpts;
10499
+ /** Cached GitHub milestone name -> ID mapping */
10500
+ milestoneCache = null;
10475
10501
  constructor(options) {
10476
10502
  this.token = options.token;
10477
10503
  this.config = options.config;
10478
10504
  this.fetchFn = options.fetchFn ?? globalThis.fetch;
10479
10505
  this.apiBase = options.apiBase ?? "https://api.github.com";
10506
+ this.retryOpts = {
10507
+ maxRetries: options.maxRetries ?? RETRY_DEFAULTS.maxRetries,
10508
+ baseDelayMs: options.baseDelayMs ?? RETRY_DEFAULTS.baseDelayMs
10509
+ };
10480
10510
  const repoParts = (options.config.repo ?? "").split("/");
10481
10511
  if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
10482
10512
  throw new Error(`Invalid repo format: "${options.config.repo}". Expected "owner/repo".`);
@@ -10484,6 +10514,43 @@ var GitHubIssuesSyncAdapter = class {
10484
10514
  this.owner = repoParts[0];
10485
10515
  this.repo = repoParts[1];
10486
10516
  }
10517
+ /**
10518
+ * Fetch all GitHub milestones and build the name -> ID cache.
10519
+ */
10520
+ async loadMilestones() {
10521
+ if (this.milestoneCache) return this.milestoneCache;
10522
+ this.milestoneCache = /* @__PURE__ */ new Map();
10523
+ const response = await fetchWithRetry(
10524
+ this.fetchFn,
10525
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/milestones?state=all&per_page=100`,
10526
+ { method: "GET", headers: this.headers() },
10527
+ this.retryOpts
10528
+ );
10529
+ if (response.ok) {
10530
+ const data = await response.json();
10531
+ for (const m of data) {
10532
+ this.milestoneCache.set(m.title, m.number);
10533
+ }
10534
+ }
10535
+ return this.milestoneCache;
10536
+ }
10537
+ /**
10538
+ * Get or create a GitHub milestone by name. Returns the milestone number.
10539
+ */
10540
+ async ensureMilestone(name) {
10541
+ const cache = await this.loadMilestones();
10542
+ if (cache.has(name)) return cache.get(name);
10543
+ const response = await fetchWithRetry(
10544
+ this.fetchFn,
10545
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/milestones`,
10546
+ { method: "POST", headers: this.headers(), body: JSON.stringify({ title: name }) },
10547
+ this.retryOpts
10548
+ );
10549
+ if (!response.ok) return null;
10550
+ const data = await response.json();
10551
+ cache.set(name, data.number);
10552
+ return data.number;
10553
+ }
10487
10554
  headers() {
10488
10555
  return {
10489
10556
  Authorization: `Bearer ${this.token}`,
@@ -10492,26 +10559,32 @@ var GitHubIssuesSyncAdapter = class {
10492
10559
  "X-GitHub-Api-Version": "2022-11-28"
10493
10560
  };
10494
10561
  }
10562
+ /**
10563
+ * Close an issue if the feature status maps to 'closed'.
10564
+ * GitHub Issues API doesn't accept state on POST — requires a follow-up PATCH.
10565
+ */
10566
+ async closeIfDone(issueNumber, featureStatus) {
10567
+ const externalStatus = this.config.statusMap[featureStatus];
10568
+ if (externalStatus !== "closed") return;
10569
+ await fetchWithRetry(
10570
+ this.fetchFn,
10571
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/issues/${issueNumber}`,
10572
+ { method: "PATCH", headers: this.headers(), body: JSON.stringify({ state: "closed" }) },
10573
+ this.retryOpts
10574
+ );
10575
+ }
10495
10576
  async createTicket(feature, milestone) {
10496
10577
  try {
10497
- const labels = labelsForStatus(feature.status, this.config);
10498
- const body = [
10499
- feature.summary,
10500
- "",
10501
- `**Milestone:** ${milestone}`,
10502
- feature.spec ? `**Spec:** ${feature.spec}` : ""
10503
- ].filter(Boolean).join("\n");
10504
- const response = await this.fetchFn(
10578
+ const labels = [...labelsForStatus(feature.status, this.config), "feature"];
10579
+ const body = [feature.summary, "", feature.spec ? `**Spec:** ${feature.spec}` : ""].filter(Boolean).join("\n");
10580
+ const milestoneId = await this.ensureMilestone(milestone);
10581
+ const issuePayload = { title: feature.name, body, labels };
10582
+ if (milestoneId) issuePayload.milestone = milestoneId;
10583
+ const response = await fetchWithRetry(
10584
+ this.fetchFn,
10505
10585
  `${this.apiBase}/repos/${this.owner}/${this.repo}/issues`,
10506
- {
10507
- method: "POST",
10508
- headers: this.headers(),
10509
- body: JSON.stringify({
10510
- title: feature.name,
10511
- body,
10512
- labels
10513
- })
10514
- }
10586
+ { method: "POST", headers: this.headers(), body: JSON.stringify(issuePayload) },
10587
+ this.retryOpts
10515
10588
  );
10516
10589
  if (!response.ok) {
10517
10590
  const text = await response.text();
@@ -10519,12 +10592,13 @@ var GitHubIssuesSyncAdapter = class {
10519
10592
  }
10520
10593
  const data = await response.json();
10521
10594
  const externalId = buildExternalId(this.owner, this.repo, data.number);
10595
+ await this.closeIfDone(data.number, feature.status);
10522
10596
  return Ok4({ externalId, url: data.html_url });
10523
10597
  } catch (error) {
10524
10598
  return Err3(error instanceof Error ? error : new Error(String(error)));
10525
10599
  }
10526
10600
  }
10527
- async updateTicket(externalId, changes) {
10601
+ async updateTicket(externalId, changes, milestone) {
10528
10602
  try {
10529
10603
  const parsed = parseExternalId(externalId);
10530
10604
  if (!parsed) return Err3(new Error(`Invalid externalId format: "${externalId}"`));
@@ -10537,15 +10611,21 @@ var GitHubIssuesSyncAdapter = class {
10537
10611
  if (changes.status !== void 0) {
10538
10612
  const externalStatus = this.config.statusMap[changes.status];
10539
10613
  patch.state = externalStatus;
10540
- patch.labels = labelsForStatus(changes.status, this.config);
10614
+ patch.labels = [...labelsForStatus(changes.status, this.config), "feature"];
10615
+ }
10616
+ if (milestone) {
10617
+ const milestoneId = await this.ensureMilestone(milestone);
10618
+ if (milestoneId) patch.milestone = milestoneId;
10541
10619
  }
10542
- const response = await this.fetchFn(
10620
+ const response = await fetchWithRetry(
10621
+ this.fetchFn,
10543
10622
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`,
10544
10623
  {
10545
10624
  method: "PATCH",
10546
10625
  headers: this.headers(),
10547
10626
  body: JSON.stringify(patch)
10548
- }
10627
+ },
10628
+ this.retryOpts
10549
10629
  );
10550
10630
  if (!response.ok) {
10551
10631
  const text = await response.text();
@@ -10561,12 +10641,14 @@ var GitHubIssuesSyncAdapter = class {
10561
10641
  try {
10562
10642
  const parsed = parseExternalId(externalId);
10563
10643
  if (!parsed) return Err3(new Error(`Invalid externalId format: "${externalId}"`));
10564
- const response = await this.fetchFn(
10644
+ const response = await fetchWithRetry(
10645
+ this.fetchFn,
10565
10646
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`,
10566
10647
  {
10567
10648
  method: "GET",
10568
10649
  headers: this.headers()
10569
- }
10650
+ },
10651
+ this.retryOpts
10570
10652
  );
10571
10653
  if (!response.ok) {
10572
10654
  const text = await response.text();
@@ -10591,12 +10673,14 @@ var GitHubIssuesSyncAdapter = class {
10591
10673
  let page = 1;
10592
10674
  const perPage = 100;
10593
10675
  while (true) {
10594
- const response = await this.fetchFn(
10676
+ const response = await fetchWithRetry(
10677
+ this.fetchFn,
10595
10678
  `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
10596
10679
  {
10597
10680
  method: "GET",
10598
10681
  headers: this.headers()
10599
- }
10682
+ },
10683
+ this.retryOpts
10600
10684
  );
10601
10685
  if (!response.ok) {
10602
10686
  const text = await response.text();
@@ -10625,13 +10709,15 @@ var GitHubIssuesSyncAdapter = class {
10625
10709
  const parsed = parseExternalId(externalId);
10626
10710
  if (!parsed) return Err3(new Error(`Invalid externalId format: "${externalId}"`));
10627
10711
  const login = assignee.startsWith("@") ? assignee.slice(1) : assignee;
10628
- const response = await this.fetchFn(
10712
+ const response = await fetchWithRetry(
10713
+ this.fetchFn,
10629
10714
  `${this.apiBase}/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}/assignees`,
10630
10715
  {
10631
10716
  method: "POST",
10632
10717
  headers: this.headers(),
10633
10718
  body: JSON.stringify({ assignees: [login] })
10634
- }
10719
+ },
10720
+ this.retryOpts
10635
10721
  );
10636
10722
  if (!response.ok) {
10637
10723
  const text = await response.text();
@@ -10662,7 +10748,11 @@ async function syncToExternal(roadmap, adapter, _config) {
10662
10748
  result.errors.push({ featureOrId: feature.name, error: createResult.error });
10663
10749
  }
10664
10750
  } else {
10665
- const updateResult = await adapter.updateTicket(feature.externalId, feature);
10751
+ const updateResult = await adapter.updateTicket(
10752
+ feature.externalId,
10753
+ feature,
10754
+ milestone.name
10755
+ );
10666
10756
  if (updateResult.ok) {
10667
10757
  result.updated.push(feature.externalId);
10668
10758
  } else {