@bragduck/cli 2.27.0 → 2.28.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.
@@ -3653,6 +3653,33 @@ var JiraService = class {
3653
3653
  return null;
3654
3654
  }
3655
3655
  }
3656
+ /**
3657
+ * Check if a user object matches the given identifier (accountId, email, or username)
3658
+ */
3659
+ isMatchingUser(candidate, userIdentifier) {
3660
+ if (!candidate) return false;
3661
+ return candidate.email === userIdentifier || candidate.emailAddress === userIdentifier || candidate.accountId === userIdentifier || candidate.username === userIdentifier || candidate.name === userIdentifier;
3662
+ }
3663
+ /**
3664
+ * Filter issues to only those where the user made contributions within the date range.
3665
+ * Excludes issues where the user's only involvement is a static role (creator/assignee)
3666
+ * with a date outside the scan period.
3667
+ */
3668
+ filterIssuesByUserContribution(issues, userIdentifier, sinceDate) {
3669
+ const results = [];
3670
+ for (const issue of issues) {
3671
+ const userChanges = (issue.changelog?.histories || []).filter(
3672
+ (h) => this.isMatchingUser(h.author, userIdentifier) && new Date(h.created) >= sinceDate
3673
+ );
3674
+ const isCreatorInRange = this.isMatchingUser(issue.fields.creator, userIdentifier) && new Date(issue.fields.created) >= sinceDate;
3675
+ if (userChanges.length > 0 || isCreatorInRange) {
3676
+ results.push({ issue, userChanges });
3677
+ } else {
3678
+ logger.debug(`Excluding issue ${issue.key} - no user contributions in date range`);
3679
+ }
3680
+ }
3681
+ return results;
3682
+ }
3656
3683
  /**
3657
3684
  * Build JQL query from options
3658
3685
  */
@@ -3710,7 +3737,7 @@ var JiraService = class {
3710
3737
  }
3711
3738
  const isAssigned = issue.fields.assignee?.emailAddress === userEmail;
3712
3739
  const isResolved = issue.fields.resolutiondate !== null && issue.fields.resolutiondate !== void 0;
3713
- const userEdits = issue.changelog?.histories?.filter((history) => history.author.emailAddress === userEmail) || [];
3740
+ const userEdits = issue.changelog?.histories?.filter((history) => history.author?.emailAddress === userEmail) || [];
3714
3741
  const hasEdits = userEdits.length > 0;
3715
3742
  if (isAssigned && isResolved) {
3716
3743
  return {
@@ -3745,6 +3772,77 @@ var JiraService = class {
3745
3772
  };
3746
3773
  return Math.ceil(baseComplexity * multipliers[contributionType]);
3747
3774
  }
3775
+ /**
3776
+ * Summarize the user's specific changes from changelog entries into human-readable lines.
3777
+ * This enriches the brag message so the AI refinement can generate a more specific brag.
3778
+ */
3779
+ summarizeUserChanges(userChanges) {
3780
+ const MAX_LINES = 10;
3781
+ const allItems = [];
3782
+ for (const entry of userChanges) {
3783
+ for (const item of entry.items) {
3784
+ allItems.push(item);
3785
+ }
3786
+ }
3787
+ if (allItems.length === 0) return "";
3788
+ const latestByField = /* @__PURE__ */ new Map();
3789
+ for (const item of allItems) {
3790
+ latestByField.set(item.field, { fromString: item.fromString, toString: item.toString });
3791
+ }
3792
+ const lines = [];
3793
+ for (const [field, change] of latestByField) {
3794
+ if (lines.length >= MAX_LINES) break;
3795
+ const from = change.fromString || "";
3796
+ const to = change.toString || "";
3797
+ switch (field.toLowerCase()) {
3798
+ case "status":
3799
+ lines.push(from ? `Moved status from '${from}' to '${to}'` : `Set status to '${to}'`);
3800
+ break;
3801
+ case "resolution":
3802
+ lines.push(to ? `Resolved as '${to}'` : "Reopened issue");
3803
+ break;
3804
+ case "assignee":
3805
+ lines.push(to ? `Assigned to ${to}` : "Unassigned");
3806
+ break;
3807
+ case "priority":
3808
+ lines.push(
3809
+ from ? `Changed priority from '${from}' to '${to}'` : `Set priority to '${to}'`
3810
+ );
3811
+ break;
3812
+ case "summary":
3813
+ lines.push("Updated issue title");
3814
+ break;
3815
+ case "description":
3816
+ lines.push("Updated description");
3817
+ break;
3818
+ case "comment":
3819
+ lines.push("Added comment");
3820
+ break;
3821
+ case "labels":
3822
+ lines.push(to ? `Updated labels: ${to}` : "Removed labels");
3823
+ break;
3824
+ case "fix version":
3825
+ case "fixversions":
3826
+ lines.push(to ? `Set fix version: ${to}` : "Removed fix version");
3827
+ break;
3828
+ case "sprint":
3829
+ lines.push(to ? `Moved to sprint: ${to}` : "Removed from sprint");
3830
+ break;
3831
+ case "story points":
3832
+ case "story point estimate":
3833
+ lines.push(`Set story points to ${to}`);
3834
+ break;
3835
+ default:
3836
+ if (to) {
3837
+ lines.push(`Updated ${field}`);
3838
+ }
3839
+ break;
3840
+ }
3841
+ }
3842
+ if (lines.length === 0) return "";
3843
+ return `User changes:
3844
+ ${lines.map((l) => `- ${l}`).join("\n")}`;
3845
+ }
3748
3846
  /**
3749
3847
  * Fetch issues with optional filtering
3750
3848
  */
@@ -3782,7 +3880,7 @@ var JiraService = class {
3782
3880
  );
3783
3881
  break;
3784
3882
  }
3785
- const endpoint = `/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=${fields.join(",")}`;
3883
+ const endpoint = `/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=${fields.join(",")}&expand=changelog`;
3786
3884
  try {
3787
3885
  const response = await this.request(endpoint);
3788
3886
  if (response.issues.length === 0) {
@@ -3808,14 +3906,7 @@ var JiraService = class {
3808
3906
  break;
3809
3907
  }
3810
3908
  if (options.limit && allIssues.length >= options.limit) {
3811
- const email2 = await this.getCurrentUser();
3812
- const limitedIssues = allIssues.slice(0, options.limit);
3813
- const commits2 = [];
3814
- for (const issue of limitedIssues) {
3815
- const commit = await this.transformIssueToCommit(issue, void 0, email2 || void 0);
3816
- commits2.push(commit);
3817
- }
3818
- return commits2;
3909
+ break;
3819
3910
  }
3820
3911
  startAt += maxResults;
3821
3912
  } catch (error) {
@@ -3839,9 +3930,23 @@ var JiraService = class {
3839
3930
  throw error;
3840
3931
  }
3841
3932
  }
3933
+ const issuesToProcess = options.limit ? allIssues.slice(0, options.limit) : allIssues;
3842
3934
  const email = await this.getCurrentUser();
3935
+ const sinceDate = options.days ? new Date(Date.now() - options.days * 24 * 60 * 60 * 1e3) : void 0;
3936
+ if (sinceDate && email) {
3937
+ const filtered = this.filterIssuesByUserContribution(issuesToProcess, email, sinceDate);
3938
+ logger.debug(
3939
+ `Date-scoped filtering: ${issuesToProcess.length} issues -> ${filtered.length} with user contributions in range`
3940
+ );
3941
+ const commits2 = [];
3942
+ for (const { issue, userChanges } of filtered) {
3943
+ const commit = await this.transformIssueToCommit(issue, void 0, email, userChanges);
3944
+ commits2.push(commit);
3945
+ }
3946
+ return commits2;
3947
+ }
3843
3948
  const commits = [];
3844
- for (const issue of allIssues) {
3949
+ for (const issue of issuesToProcess) {
3845
3950
  const commit = await this.transformIssueToCommit(issue, void 0, email || void 0);
3846
3951
  commits.push(commit);
3847
3952
  }
@@ -3863,7 +3968,7 @@ var JiraService = class {
3863
3968
  /**
3864
3969
  * Transform Jira issue to GitCommit format with contribution-specific data
3865
3970
  */
3866
- async transformIssueToCommit(issue, instanceUrl, userEmail) {
3971
+ async transformIssueToCommit(issue, instanceUrl, userEmail, userChanges) {
3867
3972
  let contribution = null;
3868
3973
  if (userEmail) {
3869
3974
  contribution = await this.determineJiraContributionType(issue, userEmail);
@@ -3900,11 +4005,21 @@ ${contribution.details}`;
3900
4005
  ${truncatedDesc}`;
3901
4006
  }
3902
4007
  }
4008
+ if (userChanges && userChanges.length > 0) {
4009
+ const changeSummary = this.summarizeUserChanges(userChanges);
4010
+ if (changeSummary) {
4011
+ message += `
4012
+
4013
+ ${changeSummary}`;
4014
+ }
4015
+ }
3903
4016
  let date;
3904
4017
  if (contribution?.type === "created" || contribution?.type === "reported") {
3905
4018
  date = issue.fields.created;
3906
4019
  } else if (contribution?.type === "assigned-resolved" && issue.fields.resolutiondate) {
3907
4020
  date = issue.fields.resolutiondate;
4021
+ } else if (userChanges && userChanges.length > 0) {
4022
+ date = userChanges[userChanges.length - 1].created;
3908
4023
  } else {
3909
4024
  date = issue.fields.updated;
3910
4025
  }
@@ -4094,6 +4209,60 @@ var ConfluenceService = class {
4094
4209
  return null;
4095
4210
  }
4096
4211
  }
4212
+ /**
4213
+ * Check if a user object matches the given identifier (accountId, email, or username)
4214
+ */
4215
+ isMatchingUser(candidate, userIdentifier) {
4216
+ if (!candidate) return false;
4217
+ return candidate.email === userIdentifier || candidate.emailAddress === userIdentifier || candidate.accountId === userIdentifier || candidate.username === userIdentifier || candidate.name === userIdentifier;
4218
+ }
4219
+ /**
4220
+ * Fetch full version history for a page
4221
+ */
4222
+ async getPageVersionHistory(pageId) {
4223
+ const allVersions = [];
4224
+ let start = 0;
4225
+ const limit = 50;
4226
+ while (true) {
4227
+ const response = await this.request(
4228
+ `/wiki/rest/api/content/${pageId}/version?start=${start}&limit=${limit}`
4229
+ );
4230
+ allVersions.push(...response.results);
4231
+ if (response.size < limit) break;
4232
+ start += limit;
4233
+ }
4234
+ return allVersions;
4235
+ }
4236
+ /**
4237
+ * Filter pages to only those where the user made contributions within the date range.
4238
+ * Fetches version history per page and checks if the user has versions in range.
4239
+ */
4240
+ async filterPagesByUserContribution(pages, userIdentifier, sinceDate) {
4241
+ const results = [];
4242
+ for (let i = 0; i < pages.length; i++) {
4243
+ const page = pages[i];
4244
+ if (i > 0) {
4245
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
4246
+ }
4247
+ try {
4248
+ const versions = await this.getPageVersionHistory(page.id);
4249
+ const userVersions = versions.filter(
4250
+ (v) => this.isMatchingUser(v.by, userIdentifier) && new Date(v.when) >= sinceDate
4251
+ );
4252
+ const userCommentsInRange = page.children?.comment?.results?.filter(
4253
+ (comment) => this.isMatchingUser(comment.version?.by || {}, userIdentifier) && new Date(comment.version?.when || 0) >= sinceDate
4254
+ ) || [];
4255
+ if (userVersions.length > 0 || userCommentsInRange.length > 0) {
4256
+ results.push({ page, userVersions });
4257
+ } else {
4258
+ logger.debug(`Excluding page "${page.title}" - no user contributions in date range`);
4259
+ }
4260
+ } catch (error) {
4261
+ logger.debug(`Skipping version history for page ${page.id}: ${error}`);
4262
+ }
4263
+ }
4264
+ return results;
4265
+ }
4097
4266
  /**
4098
4267
  * Build CQL query from options
4099
4268
  * Returns empty string if no filters need CQL (will use simple endpoint instead)
@@ -4129,28 +4298,31 @@ var ConfluenceService = class {
4129
4298
  * Determine the type of contribution the current user made to a page
4130
4299
  * Returns: 'created' | 'edited' | 'commented'
4131
4300
  */
4132
- async determineContributionType(page, userEmail) {
4133
- if (page.history?.createdBy?.email === userEmail) {
4301
+ async determineContributionType(page, userEmail, userVersions) {
4302
+ const createdByUser = page.history?.createdBy ? this.isMatchingUser(page.history.createdBy, userEmail) : false;
4303
+ if (createdByUser) {
4134
4304
  return {
4135
4305
  type: "created",
4136
4306
  details: `Created page with ${page.version?.number || 1} version${(page.version?.number || 1) > 1 ? "s" : ""}`
4137
4307
  };
4138
4308
  }
4139
- const hasEdits = page.version?.by?.email === userEmail && (page.version?.number || 0) > 1;
4309
+ const hasEdits = userVersions ? userVersions.some((v) => !v.minorEdit || v.number > 1) : page.version?.by ? this.isMatchingUser(page.version.by, userEmail) && (page.version?.number || 0) > 1 : false;
4140
4310
  const userComments = page.children?.comment?.results?.filter(
4141
- (comment) => comment.version?.by?.email === userEmail
4311
+ (comment) => comment.version?.by ? this.isMatchingUser(comment.version.by, userEmail) : false
4142
4312
  ) || [];
4143
4313
  const hasComments = userComments.length > 0;
4144
4314
  if (hasEdits && hasComments) {
4315
+ const editCount = userVersions?.length || 1;
4145
4316
  return {
4146
4317
  type: "edited",
4147
- details: `Edited page (v${page.version?.number || 1}) and added ${userComments.length} comment${userComments.length > 1 ? "s" : ""}`
4318
+ details: `Edited page (${editCount} edit${editCount > 1 ? "s" : ""}) and added ${userComments.length} comment${userComments.length > 1 ? "s" : ""}`
4148
4319
  };
4149
4320
  }
4150
4321
  if (hasEdits) {
4322
+ const editCount = userVersions?.length || 1;
4151
4323
  return {
4152
4324
  type: "edited",
4153
- details: `Edited page to version ${page.version?.number || 1}`
4325
+ details: `Edited page (${editCount} edit${editCount > 1 ? "s" : ""})`
4154
4326
  };
4155
4327
  }
4156
4328
  if (hasComments) {
@@ -4178,6 +4350,31 @@ var ConfluenceService = class {
4178
4350
  };
4179
4351
  return Math.ceil(baseSize * multipliers[contributionType]);
4180
4352
  }
4353
+ /**
4354
+ * Summarize the user's version edits into human-readable lines.
4355
+ * This enriches the brag message so the AI refinement can generate a more specific brag.
4356
+ */
4357
+ summarizeUserVersions(userVersions) {
4358
+ const MAX_ENTRIES = 5;
4359
+ const versionsWithMessages = userVersions.filter((v) => v.message && v.message.trim());
4360
+ if (versionsWithMessages.length > 0) {
4361
+ const lines = versionsWithMessages.slice(0, MAX_ENTRIES).map((v) => {
4362
+ const suffix = v.minorEdit ? " (minor edit)" : "";
4363
+ return `- v${v.number}: ${v.message.trim()}${suffix}`;
4364
+ });
4365
+ return `Edit notes:
4366
+ ${lines.join("\n")}`;
4367
+ }
4368
+ if (userVersions.length > 0) {
4369
+ const major = userVersions.filter((v) => !v.minorEdit).length;
4370
+ const minor = userVersions.filter((v) => v.minorEdit).length;
4371
+ const parts = [];
4372
+ if (major > 0) parts.push(`${major} major`);
4373
+ if (minor > 0) parts.push(`${minor} minor`);
4374
+ return `Made ${userVersions.length} edit${userVersions.length > 1 ? "s" : ""} to this page (${parts.join(", ")})`;
4375
+ }
4376
+ return "";
4377
+ }
4181
4378
  /**
4182
4379
  * Fetch pages with optional filtering
4183
4380
  */
@@ -4248,14 +4445,7 @@ var ConfluenceService = class {
4248
4445
  break;
4249
4446
  }
4250
4447
  if (options.limit && allPages.length >= options.limit) {
4251
- const email2 = await this.getCurrentUser();
4252
- const limitedPages = allPages.slice(0, options.limit);
4253
- const commits2 = [];
4254
- for (const page of limitedPages) {
4255
- const commit = await this.transformPageToCommit(page, void 0, email2 || void 0);
4256
- commits2.push(commit);
4257
- }
4258
- return commits2;
4448
+ break;
4259
4449
  }
4260
4450
  start += limit;
4261
4451
  } catch (error) {
@@ -4279,9 +4469,23 @@ var ConfluenceService = class {
4279
4469
  throw error;
4280
4470
  }
4281
4471
  }
4472
+ const pagesToProcess = options.limit ? allPages.slice(0, options.limit) : allPages;
4282
4473
  const email = await this.getCurrentUser();
4474
+ const sinceDate = options.days ? new Date(Date.now() - options.days * 24 * 60 * 60 * 1e3) : void 0;
4475
+ if (sinceDate && email) {
4476
+ const filtered = await this.filterPagesByUserContribution(pagesToProcess, email, sinceDate);
4477
+ logger.debug(
4478
+ `Date-scoped filtering: ${pagesToProcess.length} pages -> ${filtered.length} with user contributions in range`
4479
+ );
4480
+ const commits2 = [];
4481
+ for (const { page, userVersions } of filtered) {
4482
+ const commit = await this.transformPageToCommit(page, void 0, email, userVersions);
4483
+ commits2.push(commit);
4484
+ }
4485
+ return commits2;
4486
+ }
4283
4487
  const commits = [];
4284
- for (const page of allPages) {
4488
+ for (const page of pagesToProcess) {
4285
4489
  const commit = await this.transformPageToCommit(page, void 0, email || void 0);
4286
4490
  commits.push(commit);
4287
4491
  }
@@ -4303,10 +4507,10 @@ var ConfluenceService = class {
4303
4507
  /**
4304
4508
  * Transform Confluence page to GitCommit format with contribution-specific data
4305
4509
  */
4306
- async transformPageToCommit(page, instanceUrl, userEmail) {
4510
+ async transformPageToCommit(page, instanceUrl, userEmail, userVersions) {
4307
4511
  let contribution = null;
4308
4512
  if (userEmail) {
4309
- contribution = await this.determineContributionType(page, userEmail);
4513
+ contribution = await this.determineContributionType(page, userEmail, userVersions);
4310
4514
  }
4311
4515
  let message;
4312
4516
  let contributionPrefix = "";
@@ -4327,6 +4531,14 @@ ${contribution.details}
4327
4531
 
4328
4532
  [Confluence Page v${page.version?.number || 1}]`;
4329
4533
  }
4534
+ if (userVersions && userVersions.length > 0) {
4535
+ const versionSummary = this.summarizeUserVersions(userVersions);
4536
+ if (versionSummary) {
4537
+ message += `
4538
+
4539
+ ${versionSummary}`;
4540
+ }
4541
+ }
4330
4542
  let baseUrl = "https://confluence.atlassian.net";
4331
4543
  if (instanceUrl) {
4332
4544
  baseUrl = instanceUrl.startsWith("http") ? instanceUrl : `https://${instanceUrl}`;
@@ -4344,7 +4556,14 @@ ${contribution.details}
4344
4556
  const impactScore = contribution ? this.calculateContributionImpact(contribution.type, baseSize) : baseSize;
4345
4557
  const author = page.history?.createdBy?.displayName || page.version?.by?.displayName || "Unknown Author";
4346
4558
  const authorEmail = page.history?.createdBy?.email || page.version?.by?.email || "unknown@example.com";
4347
- const date = contribution?.type === "created" ? page.history?.createdDate || page.version?.when : page.version?.when || (/* @__PURE__ */ new Date()).toISOString();
4559
+ let date;
4560
+ if (contribution?.type === "created") {
4561
+ date = page.history?.createdDate || page.version?.when;
4562
+ } else if (userVersions && userVersions.length > 0) {
4563
+ date = userVersions[userVersions.length - 1].when;
4564
+ } else {
4565
+ date = page.version?.when || (/* @__PURE__ */ new Date()).toISOString();
4566
+ }
4348
4567
  return {
4349
4568
  sha: page.id,
4350
4569
  message,
@@ -5917,9 +6136,17 @@ async function syncAllAuthenticatedServices(options) {
5917
6136
  );
5918
6137
  for (const result of successful) {
5919
6138
  const serviceLabel = result.service.charAt(0).toUpperCase() + result.service.slice(1);
5920
- logger.info(
5921
- ` \u2022 ${serviceLabel}: ${result.created} brag${result.created !== 1 ? "s" : ""} created${result.skipped > 0 ? `, ${result.skipped} skipped` : ""}`
5922
- );
6139
+ if (result.created === 0 && result.skipped > 0) {
6140
+ logger.info(
6141
+ ` \u2022 ${serviceLabel}: ${colors.dim(`All ${result.skipped} item${result.skipped !== 1 ? "s" : ""} already synced`)}`
6142
+ );
6143
+ } else if (result.created === 0 && result.skipped === 0) {
6144
+ logger.info(` \u2022 ${serviceLabel}: ${colors.dim("No items found")}`);
6145
+ } else {
6146
+ logger.info(
6147
+ ` \u2022 ${serviceLabel}: ${result.created} brag${result.created !== 1 ? "s" : ""} created${result.skipped > 0 ? `, ${result.skipped} skipped` : ""}`
6148
+ );
6149
+ }
5923
6150
  }
5924
6151
  logger.log("");
5925
6152
  }