@0xbigboss/gh-pulse-core 1.0.0 → 1.1.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/cache.cjs CHANGED
@@ -86,6 +86,12 @@ class Cache {
86
86
  last_sync INTEGER NOT NULL,
87
87
  cursor TEXT
88
88
  );
89
+
90
+ CREATE TABLE IF NOT EXISTS repo_lists (
91
+ owner TEXT PRIMARY KEY,
92
+ repos JSON NOT NULL,
93
+ fetched_at INTEGER NOT NULL
94
+ );
89
95
  `);
90
96
  }
91
97
  close() {
@@ -235,6 +241,27 @@ class Cache {
235
241
  isFresh(fetchedAt, ttlHours) {
236
242
  return fetchedAt >= this.now() - ttlHours * 60 * 60 * 1000;
237
243
  }
244
+ getRepoList(owner) {
245
+ const row = this.db
246
+ .query('SELECT repos, fetched_at FROM repo_lists WHERE owner = ?')
247
+ .get(owner);
248
+ if (!row) {
249
+ return null;
250
+ }
251
+ return {
252
+ repos: JSON.parse(row.repos),
253
+ fetched_at: row.fetched_at,
254
+ };
255
+ }
256
+ upsertRepoList(owner, repos, fetchedAt = this.now()) {
257
+ this.db.run(`INSERT INTO repo_lists (owner, repos, fetched_at)
258
+ VALUES (?, ?, ?)
259
+ ON CONFLICT(owner) DO UPDATE SET repos = excluded.repos, fetched_at = excluded.fetched_at`, [owner, JSON.stringify(repos), fetchedAt]);
260
+ }
261
+ getCachedRepos() {
262
+ const rows = this.db.query('SELECT DISTINCT repo FROM sync_state').all();
263
+ return rows.map((row) => row.repo);
264
+ }
238
265
  eventExists(event, timestamp) {
239
266
  switch (event.type) {
240
267
  case 'pr_opened':
package/dist/cache.d.cts CHANGED
@@ -52,6 +52,12 @@ export declare class Cache {
52
52
  upsertSyncState(repo: RepoFullName, lastSync: Timestamp, cursor?: string | null): void;
53
53
  getFreshness(repos: RepoFullName[]): CacheFreshness;
54
54
  isFresh(fetchedAt: Timestamp, ttlHours: number): boolean;
55
+ getRepoList(owner: string): {
56
+ repos: RepoFullName[];
57
+ fetched_at: Timestamp;
58
+ } | null;
59
+ upsertRepoList(owner: string, repos: RepoFullName[], fetchedAt?: Timestamp): void;
60
+ getCachedRepos(): RepoFullName[];
55
61
  private eventExists;
56
62
  }
57
63
  export declare function getEventTimestamp(event: Event): Timestamp;
package/dist/cache.d.ts CHANGED
@@ -52,6 +52,12 @@ export declare class Cache {
52
52
  upsertSyncState(repo: RepoFullName, lastSync: Timestamp, cursor?: string | null): void;
53
53
  getFreshness(repos: RepoFullName[]): CacheFreshness;
54
54
  isFresh(fetchedAt: Timestamp, ttlHours: number): boolean;
55
+ getRepoList(owner: string): {
56
+ repos: RepoFullName[];
57
+ fetched_at: Timestamp;
58
+ } | null;
59
+ upsertRepoList(owner: string, repos: RepoFullName[], fetchedAt?: Timestamp): void;
60
+ getCachedRepos(): RepoFullName[];
55
61
  private eventExists;
56
62
  }
57
63
  export declare function getEventTimestamp(event: Event): Timestamp;
package/dist/cache.js CHANGED
@@ -78,6 +78,12 @@ export class Cache {
78
78
  last_sync INTEGER NOT NULL,
79
79
  cursor TEXT
80
80
  );
81
+
82
+ CREATE TABLE IF NOT EXISTS repo_lists (
83
+ owner TEXT PRIMARY KEY,
84
+ repos JSON NOT NULL,
85
+ fetched_at INTEGER NOT NULL
86
+ );
81
87
  `);
82
88
  }
83
89
  close() {
@@ -227,6 +233,27 @@ export class Cache {
227
233
  isFresh(fetchedAt, ttlHours) {
228
234
  return fetchedAt >= this.now() - ttlHours * 60 * 60 * 1000;
229
235
  }
236
+ getRepoList(owner) {
237
+ const row = this.db
238
+ .query('SELECT repos, fetched_at FROM repo_lists WHERE owner = ?')
239
+ .get(owner);
240
+ if (!row) {
241
+ return null;
242
+ }
243
+ return {
244
+ repos: JSON.parse(row.repos),
245
+ fetched_at: row.fetched_at,
246
+ };
247
+ }
248
+ upsertRepoList(owner, repos, fetchedAt = this.now()) {
249
+ this.db.run(`INSERT INTO repo_lists (owner, repos, fetched_at)
250
+ VALUES (?, ?, ?)
251
+ ON CONFLICT(owner) DO UPDATE SET repos = excluded.repos, fetched_at = excluded.fetched_at`, [owner, JSON.stringify(repos), fetchedAt]);
252
+ }
253
+ getCachedRepos() {
254
+ const rows = this.db.query('SELECT DISTINCT repo FROM sync_state').all();
255
+ return rows.map((row) => row.repo);
256
+ }
230
257
  eventExists(event, timestamp) {
231
258
  switch (event.type) {
232
259
  case 'pr_opened':
package/dist/config.cjs CHANGED
@@ -33,11 +33,15 @@ const ThresholdsSchema = zod_1.z
33
33
  stale_days: zod_1.z.number().int().positive().default(2),
34
34
  stuck_days: zod_1.z.number().int().positive().default(4),
35
35
  large_pr_lines: zod_1.z.number().int().positive().default(500),
36
+ // Max age for blockers - PRs older than this are excluded from blocker list
37
+ // Set to 0 to disable (show all blockers regardless of age)
38
+ blocker_max_age_days: zod_1.z.number().int().nonnegative().default(90),
36
39
  })
37
40
  .default({
38
41
  stale_days: 2,
39
42
  stuck_days: 4,
40
43
  large_pr_lines: 500,
44
+ blocker_max_age_days: 90,
41
45
  });
42
46
  const CacheSchema = zod_1.z
43
47
  .object({
package/dist/config.d.cts CHANGED
@@ -33,14 +33,17 @@ declare const ResolvedConfigSchema: z.ZodObject<{
33
33
  stale_days: z.ZodDefault<z.ZodNumber>;
34
34
  stuck_days: z.ZodDefault<z.ZodNumber>;
35
35
  large_pr_lines: z.ZodDefault<z.ZodNumber>;
36
+ blocker_max_age_days: z.ZodDefault<z.ZodNumber>;
36
37
  }, "strip", z.ZodTypeAny, {
37
38
  stale_days: number;
38
39
  stuck_days: number;
39
40
  large_pr_lines: number;
41
+ blocker_max_age_days: number;
40
42
  }, {
41
43
  stale_days?: number | undefined;
42
44
  stuck_days?: number | undefined;
43
45
  large_pr_lines?: number | undefined;
46
+ blocker_max_age_days?: number | undefined;
44
47
  }>>;
45
48
  timezone: z.ZodDefault<z.ZodString>;
46
49
  cache: z.ZodDefault<z.ZodObject<{
@@ -79,6 +82,7 @@ declare const ResolvedConfigSchema: z.ZodObject<{
79
82
  stale_days: number;
80
83
  stuck_days: number;
81
84
  large_pr_lines: number;
85
+ blocker_max_age_days: number;
82
86
  };
83
87
  timezone: string;
84
88
  cache: {
@@ -107,6 +111,7 @@ declare const ResolvedConfigSchema: z.ZodObject<{
107
111
  stale_days?: number | undefined;
108
112
  stuck_days?: number | undefined;
109
113
  large_pr_lines?: number | undefined;
114
+ blocker_max_age_days?: number | undefined;
110
115
  } | undefined;
111
116
  timezone?: string | undefined;
112
117
  cache?: {
@@ -164,14 +169,17 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
164
169
  stale_days: z.ZodDefault<z.ZodNumber>;
165
170
  stuck_days: z.ZodDefault<z.ZodNumber>;
166
171
  large_pr_lines: z.ZodDefault<z.ZodNumber>;
172
+ blocker_max_age_days: z.ZodDefault<z.ZodNumber>;
167
173
  }, "strip", z.ZodTypeAny, {
168
174
  stale_days: number;
169
175
  stuck_days: number;
170
176
  large_pr_lines: number;
177
+ blocker_max_age_days: number;
171
178
  }, {
172
179
  stale_days?: number | undefined;
173
180
  stuck_days?: number | undefined;
174
181
  large_pr_lines?: number | undefined;
182
+ blocker_max_age_days?: number | undefined;
175
183
  }>>;
176
184
  timezone: z.ZodDefault<z.ZodString>;
177
185
  cache: z.ZodDefault<z.ZodObject<{
@@ -207,6 +215,7 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
207
215
  stale_days: number;
208
216
  stuck_days: number;
209
217
  large_pr_lines: number;
218
+ blocker_max_age_days: number;
210
219
  };
211
220
  timezone: string;
212
221
  cache: {
@@ -236,6 +245,7 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
236
245
  stale_days?: number | undefined;
237
246
  stuck_days?: number | undefined;
238
247
  large_pr_lines?: number | undefined;
248
+ blocker_max_age_days?: number | undefined;
239
249
  } | undefined;
240
250
  timezone?: string | undefined;
241
251
  cache?: {
package/dist/config.d.ts CHANGED
@@ -33,14 +33,17 @@ declare const ResolvedConfigSchema: z.ZodObject<{
33
33
  stale_days: z.ZodDefault<z.ZodNumber>;
34
34
  stuck_days: z.ZodDefault<z.ZodNumber>;
35
35
  large_pr_lines: z.ZodDefault<z.ZodNumber>;
36
+ blocker_max_age_days: z.ZodDefault<z.ZodNumber>;
36
37
  }, "strip", z.ZodTypeAny, {
37
38
  stale_days: number;
38
39
  stuck_days: number;
39
40
  large_pr_lines: number;
41
+ blocker_max_age_days: number;
40
42
  }, {
41
43
  stale_days?: number | undefined;
42
44
  stuck_days?: number | undefined;
43
45
  large_pr_lines?: number | undefined;
46
+ blocker_max_age_days?: number | undefined;
44
47
  }>>;
45
48
  timezone: z.ZodDefault<z.ZodString>;
46
49
  cache: z.ZodDefault<z.ZodObject<{
@@ -79,6 +82,7 @@ declare const ResolvedConfigSchema: z.ZodObject<{
79
82
  stale_days: number;
80
83
  stuck_days: number;
81
84
  large_pr_lines: number;
85
+ blocker_max_age_days: number;
82
86
  };
83
87
  timezone: string;
84
88
  cache: {
@@ -107,6 +111,7 @@ declare const ResolvedConfigSchema: z.ZodObject<{
107
111
  stale_days?: number | undefined;
108
112
  stuck_days?: number | undefined;
109
113
  large_pr_lines?: number | undefined;
114
+ blocker_max_age_days?: number | undefined;
110
115
  } | undefined;
111
116
  timezone?: string | undefined;
112
117
  cache?: {
@@ -164,14 +169,17 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
164
169
  stale_days: z.ZodDefault<z.ZodNumber>;
165
170
  stuck_days: z.ZodDefault<z.ZodNumber>;
166
171
  large_pr_lines: z.ZodDefault<z.ZodNumber>;
172
+ blocker_max_age_days: z.ZodDefault<z.ZodNumber>;
167
173
  }, "strip", z.ZodTypeAny, {
168
174
  stale_days: number;
169
175
  stuck_days: number;
170
176
  large_pr_lines: number;
177
+ blocker_max_age_days: number;
171
178
  }, {
172
179
  stale_days?: number | undefined;
173
180
  stuck_days?: number | undefined;
174
181
  large_pr_lines?: number | undefined;
182
+ blocker_max_age_days?: number | undefined;
175
183
  }>>;
176
184
  timezone: z.ZodDefault<z.ZodString>;
177
185
  cache: z.ZodDefault<z.ZodObject<{
@@ -207,6 +215,7 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
207
215
  stale_days: number;
208
216
  stuck_days: number;
209
217
  large_pr_lines: number;
218
+ blocker_max_age_days: number;
210
219
  };
211
220
  timezone: string;
212
221
  cache: {
@@ -236,6 +245,7 @@ export declare const CONFIG_SCHEMA: z.ZodObject<{
236
245
  stale_days?: number | undefined;
237
246
  stuck_days?: number | undefined;
238
247
  large_pr_lines?: number | undefined;
248
+ blocker_max_age_days?: number | undefined;
239
249
  } | undefined;
240
250
  timezone?: string | undefined;
241
251
  cache?: {
package/dist/config.js CHANGED
@@ -23,11 +23,15 @@ const ThresholdsSchema = z
23
23
  stale_days: z.number().int().positive().default(2),
24
24
  stuck_days: z.number().int().positive().default(4),
25
25
  large_pr_lines: z.number().int().positive().default(500),
26
+ // Max age for blockers - PRs older than this are excluded from blocker list
27
+ // Set to 0 to disable (show all blockers regardless of age)
28
+ blocker_max_age_days: z.number().int().nonnegative().default(90),
26
29
  })
27
30
  .default({
28
31
  stale_days: 2,
29
32
  stuck_days: 4,
30
33
  large_pr_lines: 500,
34
+ blocker_max_age_days: 90,
31
35
  });
32
36
  const CacheSchema = z
33
37
  .object({
@@ -114,19 +114,40 @@ function buildHighlights(mergedPrs, largePrThreshold, now) {
114
114
  })
115
115
  .filter((entry) => Boolean(entry));
116
116
  }
117
+ /**
118
+ * Case-insensitive username comparison.
119
+ */
120
+ function usernameMatches(a, b) {
121
+ return a.toLowerCase() === b.toLowerCase();
122
+ }
123
+ /**
124
+ * Deduplicate team members case-insensitively, preserving first occurrence.
125
+ */
126
+ function dedupeMembers(members) {
127
+ const seen = new Map();
128
+ for (const member of members) {
129
+ const key = member.toLowerCase();
130
+ if (!seen.has(key)) {
131
+ seen.set(key, member);
132
+ }
133
+ }
134
+ return Array.from(seen.values());
135
+ }
117
136
  function buildTeamBreakdown(teamMembers, pullRequests, commits, events, exclude, excludeBots, since, until) {
118
137
  const reviewEvents = events.filter((event) => event.type === 'review_submitted');
119
- return [...teamMembers]
138
+ // Dedupe members case-insensitively
139
+ const uniqueMembers = dedupeMembers(teamMembers);
140
+ return uniqueMembers
120
141
  .filter((member) => !(0, filters_1.isExcludedAuthor)(member, exclude, excludeBots))
121
142
  .map((member) => {
122
- const authored = pullRequests.filter((pr) => pr.author === member &&
143
+ const authored = pullRequests.filter((pr) => usernameMatches(pr.author, member) &&
123
144
  pr.created_at >= since &&
124
145
  pr.created_at <= until &&
125
146
  !(0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots));
126
147
  const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
127
- event.reviewer === member &&
148
+ usernameMatches(event.reviewer, member) &&
128
149
  !(0, filters_1.isExcludedAuthor)(event.reviewer, exclude, excludeBots));
129
- const memberCommits = commits.filter((commit) => commit.author === member &&
150
+ const memberCommits = commits.filter((commit) => usernameMatches(commit.author, member) &&
130
151
  commit.committed_at >= since &&
131
152
  commit.committed_at <= until &&
132
153
  !(0, filters_1.isExcludedAuthor)(commit.author, exclude, excludeBots));
@@ -111,19 +111,40 @@ function buildHighlights(mergedPrs, largePrThreshold, now) {
111
111
  })
112
112
  .filter((entry) => Boolean(entry));
113
113
  }
114
+ /**
115
+ * Case-insensitive username comparison.
116
+ */
117
+ function usernameMatches(a, b) {
118
+ return a.toLowerCase() === b.toLowerCase();
119
+ }
120
+ /**
121
+ * Deduplicate team members case-insensitively, preserving first occurrence.
122
+ */
123
+ function dedupeMembers(members) {
124
+ const seen = new Map();
125
+ for (const member of members) {
126
+ const key = member.toLowerCase();
127
+ if (!seen.has(key)) {
128
+ seen.set(key, member);
129
+ }
130
+ }
131
+ return Array.from(seen.values());
132
+ }
114
133
  function buildTeamBreakdown(teamMembers, pullRequests, commits, events, exclude, excludeBots, since, until) {
115
134
  const reviewEvents = events.filter((event) => event.type === 'review_submitted');
116
- return [...teamMembers]
135
+ // Dedupe members case-insensitively
136
+ const uniqueMembers = dedupeMembers(teamMembers);
137
+ return uniqueMembers
117
138
  .filter((member) => !isExcludedAuthor(member, exclude, excludeBots))
118
139
  .map((member) => {
119
- const authored = pullRequests.filter((pr) => pr.author === member &&
140
+ const authored = pullRequests.filter((pr) => usernameMatches(pr.author, member) &&
120
141
  pr.created_at >= since &&
121
142
  pr.created_at <= until &&
122
143
  !isExcludedAuthor(pr.author, exclude, excludeBots));
123
144
  const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
124
- event.reviewer === member &&
145
+ usernameMatches(event.reviewer, member) &&
125
146
  !isExcludedAuthor(event.reviewer, exclude, excludeBots));
126
- const memberCommits = commits.filter((commit) => commit.author === member &&
147
+ const memberCommits = commits.filter((commit) => usernameMatches(commit.author, member) &&
127
148
  commit.committed_at >= since &&
128
149
  commit.committed_at <= until &&
129
150
  !isExcludedAuthor(commit.author, exclude, excludeBots));
@@ -4,6 +4,12 @@ exports.buildPersonalReport = buildPersonalReport;
4
4
  const utils_1 = require("./utils.cjs");
5
5
  const context_1 = require("./context.cjs");
6
6
  const filters_1 = require("../filters.cjs");
7
+ /**
8
+ * Case-insensitive username comparison.
9
+ */
10
+ function usernameMatches(a, b) {
11
+ return a.toLowerCase() === b.toLowerCase();
12
+ }
7
13
  function buildPersonalReport(cache, options) {
8
14
  const { pullRequests, commits } = (0, context_1.loadReportData)(cache, options.repos);
9
15
  const now = options.meta.generated_at;
@@ -12,7 +18,8 @@ function buildPersonalReport(cache, options) {
12
18
  const events = cache
13
19
  .getEvents({ since: options.timeRange.start, until: options.timeRange.end })
14
20
  .filter((event) => options.repos.includes(event.repo));
15
- const authoredPrs = pullRequests.filter((pr) => pr.author === options.user && !(0, filters_1.isExcludedAuthor)(pr.author, exclude, options.excludeBots));
21
+ const authoredPrs = pullRequests.filter((pr) => usernameMatches(pr.author, options.user) &&
22
+ !(0, filters_1.isExcludedAuthor)(pr.author, exclude, options.excludeBots));
16
23
  const authoredSummaries = authoredPrs
17
24
  .filter((pr) => pr.created_at >= options.timeRange.start && pr.created_at <= options.timeRange.end)
18
25
  .map((pr) => (0, utils_1.buildPullRequestSummary)(pr, now));
@@ -23,19 +30,20 @@ function buildPersonalReport(cache, options) {
23
30
  const requested = pullRequests
24
31
  .filter((pr) => pr.state === 'open')
25
32
  .filter((pr) => options.includeDrafts || !pr.draft)
26
- .filter((pr) => pr.requested_reviewers.includes(options.user))
33
+ .filter((pr) => pr.requested_reviewers.some((reviewer) => usernameMatches(reviewer, options.user)))
27
34
  .filter((pr) => !(0, filters_1.isExcludedAuthor)(pr.author, exclude, options.excludeBots))
28
35
  .map((pr) => (0, utils_1.buildPullRequestSummary)(pr, now));
29
- const teamMembers = new Set(options.teamMembers);
36
+ // Use lowercase keys for case-insensitive membership checks
37
+ const teamMembers = new Set(options.teamMembers.map((m) => m.toLowerCase()));
30
38
  const teamPrs = pullRequests
31
39
  .filter((pr) => pr.state === 'open')
32
40
  .filter((pr) => options.includeDrafts || !pr.draft)
33
- .filter((pr) => pr.author !== options.user)
34
- .filter((pr) => teamMembers.has(pr.author))
41
+ .filter((pr) => !usernameMatches(pr.author, options.user))
42
+ .filter((pr) => teamMembers.has(pr.author.toLowerCase()))
35
43
  .filter((pr) => !(0, filters_1.isExcludedAuthor)(pr.author, exclude, options.excludeBots))
36
44
  .map((pr) => (0, utils_1.buildPullRequestSummary)(pr, now));
37
45
  const commitsForUser = commits
38
- .filter((commit) => commit.author === options.user)
46
+ .filter((commit) => usernameMatches(commit.author, options.user))
39
47
  .filter((commit) => commit.committed_at >= options.timeRange.start)
40
48
  .filter((commit) => commit.committed_at <= options.timeRange.end)
41
49
  .filter((commit) => !(0, filters_1.isExcludedAuthor)(commit.author, exclude, options.excludeBots));
@@ -67,7 +75,7 @@ function buildPersonalReport(cache, options) {
67
75
  };
68
76
  }
69
77
  function buildPersonalSummary(events, prLookup, user, commits) {
70
- const opened = events.filter((event) => event.type === 'pr_opened' && event.author === user);
78
+ const opened = events.filter((event) => event.type === 'pr_opened' && usernameMatches(event.author, user));
71
79
  const merged = events.filter((event) => event.type === 'pr_merged' && prMatchesAuthor(prLookup, event.repo, event.pr_number, user));
72
80
  const closed = events.filter((event) => event.type === 'pr_closed' && prMatchesAuthor(prLookup, event.repo, event.pr_number, user));
73
81
  const reposTouched = new Set(commits.map((commit) => commit.repo));
@@ -83,7 +91,7 @@ function buildPersonalSummary(events, prLookup, user, commits) {
83
91
  }
84
92
  function prMatchesAuthor(lookup, repo, number, user) {
85
93
  const pr = lookup.get(`${repo}#${number}`);
86
- return pr ? pr.author === user : false;
94
+ return pr ? usernameMatches(pr.author, user) : false;
87
95
  }
88
96
  function buildPullRequestLookup(pullRequests) {
89
97
  return new Map(pullRequests.map((pr) => [`${pr.repo}#${pr.number}`, pr]));
@@ -1,6 +1,12 @@
1
1
  import { buildPullRequestSummary } from "./utils.js";
2
2
  import { loadReportData } from "./context.js";
3
3
  import { isExcludedAuthor } from "../filters.js";
4
+ /**
5
+ * Case-insensitive username comparison.
6
+ */
7
+ function usernameMatches(a, b) {
8
+ return a.toLowerCase() === b.toLowerCase();
9
+ }
4
10
  export function buildPersonalReport(cache, options) {
5
11
  const { pullRequests, commits } = loadReportData(cache, options.repos);
6
12
  const now = options.meta.generated_at;
@@ -9,7 +15,8 @@ export function buildPersonalReport(cache, options) {
9
15
  const events = cache
10
16
  .getEvents({ since: options.timeRange.start, until: options.timeRange.end })
11
17
  .filter((event) => options.repos.includes(event.repo));
12
- const authoredPrs = pullRequests.filter((pr) => pr.author === options.user && !isExcludedAuthor(pr.author, exclude, options.excludeBots));
18
+ const authoredPrs = pullRequests.filter((pr) => usernameMatches(pr.author, options.user) &&
19
+ !isExcludedAuthor(pr.author, exclude, options.excludeBots));
13
20
  const authoredSummaries = authoredPrs
14
21
  .filter((pr) => pr.created_at >= options.timeRange.start && pr.created_at <= options.timeRange.end)
15
22
  .map((pr) => buildPullRequestSummary(pr, now));
@@ -20,19 +27,20 @@ export function buildPersonalReport(cache, options) {
20
27
  const requested = pullRequests
21
28
  .filter((pr) => pr.state === 'open')
22
29
  .filter((pr) => options.includeDrafts || !pr.draft)
23
- .filter((pr) => pr.requested_reviewers.includes(options.user))
30
+ .filter((pr) => pr.requested_reviewers.some((reviewer) => usernameMatches(reviewer, options.user)))
24
31
  .filter((pr) => !isExcludedAuthor(pr.author, exclude, options.excludeBots))
25
32
  .map((pr) => buildPullRequestSummary(pr, now));
26
- const teamMembers = new Set(options.teamMembers);
33
+ // Use lowercase keys for case-insensitive membership checks
34
+ const teamMembers = new Set(options.teamMembers.map((m) => m.toLowerCase()));
27
35
  const teamPrs = pullRequests
28
36
  .filter((pr) => pr.state === 'open')
29
37
  .filter((pr) => options.includeDrafts || !pr.draft)
30
- .filter((pr) => pr.author !== options.user)
31
- .filter((pr) => teamMembers.has(pr.author))
38
+ .filter((pr) => !usernameMatches(pr.author, options.user))
39
+ .filter((pr) => teamMembers.has(pr.author.toLowerCase()))
32
40
  .filter((pr) => !isExcludedAuthor(pr.author, exclude, options.excludeBots))
33
41
  .map((pr) => buildPullRequestSummary(pr, now));
34
42
  const commitsForUser = commits
35
- .filter((commit) => commit.author === options.user)
43
+ .filter((commit) => usernameMatches(commit.author, options.user))
36
44
  .filter((commit) => commit.committed_at >= options.timeRange.start)
37
45
  .filter((commit) => commit.committed_at <= options.timeRange.end)
38
46
  .filter((commit) => !isExcludedAuthor(commit.author, exclude, options.excludeBots));
@@ -64,7 +72,7 @@ export function buildPersonalReport(cache, options) {
64
72
  };
65
73
  }
66
74
  function buildPersonalSummary(events, prLookup, user, commits) {
67
- const opened = events.filter((event) => event.type === 'pr_opened' && event.author === user);
75
+ const opened = events.filter((event) => event.type === 'pr_opened' && usernameMatches(event.author, user));
68
76
  const merged = events.filter((event) => event.type === 'pr_merged' && prMatchesAuthor(prLookup, event.repo, event.pr_number, user));
69
77
  const closed = events.filter((event) => event.type === 'pr_closed' && prMatchesAuthor(prLookup, event.repo, event.pr_number, user));
70
78
  const reposTouched = new Set(commits.map((commit) => commit.repo));
@@ -80,7 +88,7 @@ function buildPersonalSummary(events, prLookup, user, commits) {
80
88
  }
81
89
  function prMatchesAuthor(lookup, repo, number, user) {
82
90
  const pr = lookup.get(`${repo}#${number}`);
83
- return pr ? pr.author === user : false;
91
+ return pr ? usernameMatches(pr.author, user) : false;
84
92
  }
85
93
  function buildPullRequestLookup(pullRequests) {
86
94
  return new Map(pullRequests.map((pr) => [`${pr.repo}#${pr.number}`, pr]));
@@ -8,7 +8,8 @@ function buildTeamReport(cache, options) {
8
8
  const { pullRequests, commits } = (0, context_1.loadReportData)(cache, options.repos);
9
9
  const now = options.meta.generated_at;
10
10
  const exclude = options.excludeAuthors;
11
- const teamSet = new Set(options.teamMembers);
11
+ // Use lowercase keys for case-insensitive membership checks
12
+ const teamSet = new Set(options.teamMembers.map((m) => m.toLowerCase()));
12
13
  const prLookup = buildPullRequestLookup(pullRequests);
13
14
  const events = cache
14
15
  .getEvents({ since: options.timeRange.start, until: options.timeRange.end })
@@ -43,7 +44,7 @@ function buildTeamReport(cache, options) {
43
44
  return { pr: (0, utils_1.buildPullRequestSummary)(pr, now), metrics };
44
45
  });
45
46
  const memberBreakdown = buildMemberBreakdown(options.teamMembers, pullRequests, commits, reviewEvents, exclude, options.excludeBots, options.timeRange.start, options.timeRange.end);
46
- const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now);
47
+ const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now, options.timeRange);
47
48
  return {
48
49
  type: 'team',
49
50
  meta: options.meta,
@@ -63,18 +64,39 @@ function buildTeamReport(cache, options) {
63
64
  review_engagement: reviewEngagement,
64
65
  };
65
66
  }
67
+ /**
68
+ * Case-insensitive username comparison.
69
+ */
70
+ function usernameMatches(a, b) {
71
+ return a.toLowerCase() === b.toLowerCase();
72
+ }
73
+ /**
74
+ * Deduplicate team members case-insensitively, preserving first occurrence.
75
+ */
76
+ function dedupeMembers(members) {
77
+ const seen = new Map();
78
+ for (const member of members) {
79
+ const key = member.toLowerCase();
80
+ if (!seen.has(key)) {
81
+ seen.set(key, member);
82
+ }
83
+ }
84
+ return Array.from(seen.values());
85
+ }
66
86
  function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents, exclude, excludeBots, since, until) {
67
- return [...teamMembers]
87
+ // Dedupe members case-insensitively
88
+ const uniqueMembers = dedupeMembers(teamMembers);
89
+ return uniqueMembers
68
90
  .filter((member) => !(0, filters_1.isExcludedAuthor)(member, exclude, excludeBots))
69
91
  .map((member) => {
70
- const authored = pullRequests.filter((pr) => pr.author === member &&
92
+ const authored = pullRequests.filter((pr) => usernameMatches(pr.author, member) &&
71
93
  pr.created_at >= since &&
72
94
  pr.created_at <= until &&
73
95
  !(0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots));
74
96
  const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
75
- event.reviewer === member &&
97
+ usernameMatches(event.reviewer, member) &&
76
98
  !(0, filters_1.isExcludedAuthor)(event.reviewer, exclude, excludeBots));
77
- const memberCommits = commits.filter((commit) => commit.author === member &&
99
+ const memberCommits = commits.filter((commit) => usernameMatches(commit.author, member) &&
78
100
  commit.committed_at >= since &&
79
101
  commit.committed_at <= until &&
80
102
  !(0, filters_1.isExcludedAuthor)(commit.author, exclude, excludeBots));
@@ -89,11 +111,32 @@ function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents,
89
111
  })
90
112
  .toSorted((a, b) => a.user.localeCompare(b.user));
91
113
  }
92
- function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now) {
114
+ /**
115
+ * Find blockers (stale/stuck PRs) with configurable age filtering.
116
+ *
117
+ * A PR is included in the blocker list if:
118
+ * - It had activity (was updated) within the time range, OR
119
+ * - It was created within the time range, OR
120
+ * - blocker_max_age_days is 0 (disabled), OR
121
+ * - The PR is younger than blocker_max_age_days
122
+ */
123
+ function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now, timeRange) {
124
+ const maxAgeDays = thresholds.blocker_max_age_days;
93
125
  return pullRequests
94
126
  .filter((pr) => pr.state === 'open')
95
127
  .filter((pr) => includeDrafts || !pr.draft)
96
128
  .filter((pr) => !(0, filters_1.isExcludedAuthor)(pr.author, exclude, excludeBots))
129
+ .filter((pr) => {
130
+ // If max age is disabled (0), include all
131
+ if (maxAgeDays === 0)
132
+ return true;
133
+ const prAgeDays = diffDays(pr.created_at, now);
134
+ const hadActivityInRange = pr.updated_at >= timeRange.start && pr.updated_at <= timeRange.end;
135
+ const createdInRange = pr.created_at >= timeRange.start && pr.created_at <= timeRange.end;
136
+ const withinMaxAge = prAgeDays <= maxAgeDays;
137
+ // Include if: had recent activity, was created in range, or is within max age
138
+ return hadActivityInRange || createdInRange || withinMaxAge;
139
+ })
97
140
  .flatMap((pr) => {
98
141
  const blockers = [];
99
142
  const summary = (0, utils_1.buildPullRequestSummary)(pr, now);
@@ -112,8 +155,11 @@ function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresho
112
155
  function diffDays(start, end) {
113
156
  return Math.floor((end - start) / (24 * 60 * 60 * 1000));
114
157
  }
158
+ /**
159
+ * Case-insensitive check if author is in team.
160
+ */
115
161
  function teamHasAuthor(team, author) {
116
- return team.has(author);
162
+ return team.has(author.toLowerCase());
117
163
  }
118
164
  function prAuthorInTeam(lookup, team, repo, number, exclude, excludeBots) {
119
165
  const pr = lookup.get(`${repo}#${number}`);
@@ -5,7 +5,8 @@ export function buildTeamReport(cache, options) {
5
5
  const { pullRequests, commits } = loadReportData(cache, options.repos);
6
6
  const now = options.meta.generated_at;
7
7
  const exclude = options.excludeAuthors;
8
- const teamSet = new Set(options.teamMembers);
8
+ // Use lowercase keys for case-insensitive membership checks
9
+ const teamSet = new Set(options.teamMembers.map((m) => m.toLowerCase()));
9
10
  const prLookup = buildPullRequestLookup(pullRequests);
10
11
  const events = cache
11
12
  .getEvents({ since: options.timeRange.start, until: options.timeRange.end })
@@ -40,7 +41,7 @@ export function buildTeamReport(cache, options) {
40
41
  return { pr: buildPullRequestSummary(pr, now), metrics };
41
42
  });
42
43
  const memberBreakdown = buildMemberBreakdown(options.teamMembers, pullRequests, commits, reviewEvents, exclude, options.excludeBots, options.timeRange.start, options.timeRange.end);
43
- const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now);
44
+ const blockers = findBlockers(pullRequests, options.includeDrafts, exclude, options.excludeBots, options.thresholds, now, options.timeRange);
44
45
  return {
45
46
  type: 'team',
46
47
  meta: options.meta,
@@ -60,18 +61,39 @@ export function buildTeamReport(cache, options) {
60
61
  review_engagement: reviewEngagement,
61
62
  };
62
63
  }
64
+ /**
65
+ * Case-insensitive username comparison.
66
+ */
67
+ function usernameMatches(a, b) {
68
+ return a.toLowerCase() === b.toLowerCase();
69
+ }
70
+ /**
71
+ * Deduplicate team members case-insensitively, preserving first occurrence.
72
+ */
73
+ function dedupeMembers(members) {
74
+ const seen = new Map();
75
+ for (const member of members) {
76
+ const key = member.toLowerCase();
77
+ if (!seen.has(key)) {
78
+ seen.set(key, member);
79
+ }
80
+ }
81
+ return Array.from(seen.values());
82
+ }
63
83
  function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents, exclude, excludeBots, since, until) {
64
- return [...teamMembers]
84
+ // Dedupe members case-insensitively
85
+ const uniqueMembers = dedupeMembers(teamMembers);
86
+ return uniqueMembers
65
87
  .filter((member) => !isExcludedAuthor(member, exclude, excludeBots))
66
88
  .map((member) => {
67
- const authored = pullRequests.filter((pr) => pr.author === member &&
89
+ const authored = pullRequests.filter((pr) => usernameMatches(pr.author, member) &&
68
90
  pr.created_at >= since &&
69
91
  pr.created_at <= until &&
70
92
  !isExcludedAuthor(pr.author, exclude, excludeBots));
71
93
  const reviewed = reviewEvents.filter((event) => event.type === 'review_submitted' &&
72
- event.reviewer === member &&
94
+ usernameMatches(event.reviewer, member) &&
73
95
  !isExcludedAuthor(event.reviewer, exclude, excludeBots));
74
- const memberCommits = commits.filter((commit) => commit.author === member &&
96
+ const memberCommits = commits.filter((commit) => usernameMatches(commit.author, member) &&
75
97
  commit.committed_at >= since &&
76
98
  commit.committed_at <= until &&
77
99
  !isExcludedAuthor(commit.author, exclude, excludeBots));
@@ -86,11 +108,32 @@ function buildMemberBreakdown(teamMembers, pullRequests, commits, reviewEvents,
86
108
  })
87
109
  .toSorted((a, b) => a.user.localeCompare(b.user));
88
110
  }
89
- function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now) {
111
+ /**
112
+ * Find blockers (stale/stuck PRs) with configurable age filtering.
113
+ *
114
+ * A PR is included in the blocker list if:
115
+ * - It had activity (was updated) within the time range, OR
116
+ * - It was created within the time range, OR
117
+ * - blocker_max_age_days is 0 (disabled), OR
118
+ * - The PR is younger than blocker_max_age_days
119
+ */
120
+ function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresholds, now, timeRange) {
121
+ const maxAgeDays = thresholds.blocker_max_age_days;
90
122
  return pullRequests
91
123
  .filter((pr) => pr.state === 'open')
92
124
  .filter((pr) => includeDrafts || !pr.draft)
93
125
  .filter((pr) => !isExcludedAuthor(pr.author, exclude, excludeBots))
126
+ .filter((pr) => {
127
+ // If max age is disabled (0), include all
128
+ if (maxAgeDays === 0)
129
+ return true;
130
+ const prAgeDays = diffDays(pr.created_at, now);
131
+ const hadActivityInRange = pr.updated_at >= timeRange.start && pr.updated_at <= timeRange.end;
132
+ const createdInRange = pr.created_at >= timeRange.start && pr.created_at <= timeRange.end;
133
+ const withinMaxAge = prAgeDays <= maxAgeDays;
134
+ // Include if: had recent activity, was created in range, or is within max age
135
+ return hadActivityInRange || createdInRange || withinMaxAge;
136
+ })
94
137
  .flatMap((pr) => {
95
138
  const blockers = [];
96
139
  const summary = buildPullRequestSummary(pr, now);
@@ -109,8 +152,11 @@ function findBlockers(pullRequests, includeDrafts, exclude, excludeBots, thresho
109
152
  function diffDays(start, end) {
110
153
  return Math.floor((end - start) / (24 * 60 * 60 * 1000));
111
154
  }
155
+ /**
156
+ * Case-insensitive check if author is in team.
157
+ */
112
158
  function teamHasAuthor(team, author) {
113
- return team.has(author);
159
+ return team.has(author.toLowerCase());
114
160
  }
115
161
  function prAuthorInTeam(lookup, team, repo, number, exclude, excludeBots) {
116
162
  const pr = lookup.get(`${repo}#${number}`);
@@ -136,6 +136,7 @@ export interface ReportInputs {
136
136
  stale_days: number;
137
137
  stuck_days: number;
138
138
  large_pr_lines: number;
139
+ blocker_max_age_days: number;
139
140
  };
140
141
  }
141
142
  export interface ReportDataContext {
@@ -136,6 +136,7 @@ export interface ReportInputs {
136
136
  stale_days: number;
137
137
  stuck_days: number;
138
138
  large_pr_lines: number;
139
+ blocker_max_age_days: number;
139
140
  };
140
141
  }
141
142
  export interface ReportDataContext {
package/dist/repos.cjs CHANGED
@@ -8,9 +8,25 @@ const picomatch_1 = __importDefault(require("picomatch"));
8
8
  async function resolveRepos(options) {
9
9
  const orgs = uniqueStrings([...(options.config.orgs ?? []), ...(options.orgs ?? [])]);
10
10
  const repoPatterns = options.repoPatterns ?? [];
11
+ const { cache, forceSync } = options;
12
+ const cacheTtlHours = options.config.cache.ttl_hours;
11
13
  const repoSet = new Set();
12
14
  // orgs can include GitHub orgs OR usernames - we try org first, fall back to user
13
15
  for (const [index, org] of orgs.entries()) {
16
+ // Check cache first (unless forceSync)
17
+ if (cache && !forceSync) {
18
+ const cached = cache.getRepoList(org);
19
+ if (cached && cache.isFresh(cached.fetched_at, cacheTtlHours)) {
20
+ options.onProgress?.({
21
+ phase: 'resolve',
22
+ message: `Using cached repos for ${org}`,
23
+ current: index + 1,
24
+ total: orgs.length,
25
+ });
26
+ cached.repos.forEach((repo) => repoSet.add(repo));
27
+ continue;
28
+ }
29
+ }
14
30
  options.onProgress?.({
15
31
  phase: 'resolve',
16
32
  message: `Resolving repos for ${org}`,
@@ -19,6 +35,10 @@ async function resolveRepos(options) {
19
35
  });
20
36
  const repos = await listOwnerRepos(options.github, org);
21
37
  repos.forEach((repo) => repoSet.add(repo));
38
+ // Cache the result
39
+ if (cache) {
40
+ cache.upsertRepoList(org, repos);
41
+ }
22
42
  }
23
43
  for (const pattern of options.config.repos ?? []) {
24
44
  if (pattern.includes('*')) {
@@ -26,11 +46,36 @@ async function resolveRepos(options) {
26
46
  if (!owner) {
27
47
  throw new Error(`Invalid repo pattern: ${pattern}`);
28
48
  }
29
- options.onProgress?.({
30
- phase: 'resolve',
31
- message: `Resolving repos for ${owner}`,
32
- });
33
- const repos = await listOwnerRepos(options.github, owner);
49
+ // Check cache first (unless forceSync)
50
+ let repos;
51
+ if (cache && !forceSync) {
52
+ const cached = cache.getRepoList(owner);
53
+ if (cached && cache.isFresh(cached.fetched_at, cacheTtlHours)) {
54
+ options.onProgress?.({
55
+ phase: 'resolve',
56
+ message: `Using cached repos for ${owner}`,
57
+ });
58
+ repos = cached.repos;
59
+ }
60
+ else {
61
+ options.onProgress?.({
62
+ phase: 'resolve',
63
+ message: `Resolving repos for ${owner}`,
64
+ });
65
+ repos = await listOwnerRepos(options.github, owner);
66
+ cache.upsertRepoList(owner, repos);
67
+ }
68
+ }
69
+ else {
70
+ options.onProgress?.({
71
+ phase: 'resolve',
72
+ message: `Resolving repos for ${owner}`,
73
+ });
74
+ repos = await listOwnerRepos(options.github, owner);
75
+ if (cache) {
76
+ cache.upsertRepoList(owner, repos);
77
+ }
78
+ }
34
79
  const matcher = (0, picomatch_1.default)(pattern);
35
80
  repos.filter((repo) => matcher(repo)).forEach((repo) => repoSet.add(repo));
36
81
  continue;
package/dist/repos.d.cts CHANGED
@@ -1,12 +1,15 @@
1
+ import type { Cache } from "./cache.cjs";
1
2
  import type { Config } from "./config.cjs";
2
3
  import type { GitHubClient } from "./github.cjs";
3
4
  import type { ProgressEvent, RepoFullName } from "./types.cjs";
4
5
  export interface ResolveReposOptions {
5
6
  config: Config;
6
7
  github: GitHubClient;
8
+ cache?: Cache;
7
9
  orgs?: string[];
8
10
  repoPatterns?: string[];
9
11
  strict?: boolean;
12
+ forceSync?: boolean;
10
13
  onProgress?: (event: ProgressEvent) => void;
11
14
  }
12
15
  export declare function resolveRepos(options: ResolveReposOptions): Promise<RepoFullName[]>;
package/dist/repos.d.ts CHANGED
@@ -1,12 +1,15 @@
1
+ import type { Cache } from "./cache.js";
1
2
  import type { Config } from "./config.js";
2
3
  import type { GitHubClient } from "./github.js";
3
4
  import type { ProgressEvent, RepoFullName } from "./types.js";
4
5
  export interface ResolveReposOptions {
5
6
  config: Config;
6
7
  github: GitHubClient;
8
+ cache?: Cache;
7
9
  orgs?: string[];
8
10
  repoPatterns?: string[];
9
11
  strict?: boolean;
12
+ forceSync?: boolean;
10
13
  onProgress?: (event: ProgressEvent) => void;
11
14
  }
12
15
  export declare function resolveRepos(options: ResolveReposOptions): Promise<RepoFullName[]>;
package/dist/repos.js CHANGED
@@ -2,9 +2,25 @@ import picomatch from 'picomatch';
2
2
  export async function resolveRepos(options) {
3
3
  const orgs = uniqueStrings([...(options.config.orgs ?? []), ...(options.orgs ?? [])]);
4
4
  const repoPatterns = options.repoPatterns ?? [];
5
+ const { cache, forceSync } = options;
6
+ const cacheTtlHours = options.config.cache.ttl_hours;
5
7
  const repoSet = new Set();
6
8
  // orgs can include GitHub orgs OR usernames - we try org first, fall back to user
7
9
  for (const [index, org] of orgs.entries()) {
10
+ // Check cache first (unless forceSync)
11
+ if (cache && !forceSync) {
12
+ const cached = cache.getRepoList(org);
13
+ if (cached && cache.isFresh(cached.fetched_at, cacheTtlHours)) {
14
+ options.onProgress?.({
15
+ phase: 'resolve',
16
+ message: `Using cached repos for ${org}`,
17
+ current: index + 1,
18
+ total: orgs.length,
19
+ });
20
+ cached.repos.forEach((repo) => repoSet.add(repo));
21
+ continue;
22
+ }
23
+ }
8
24
  options.onProgress?.({
9
25
  phase: 'resolve',
10
26
  message: `Resolving repos for ${org}`,
@@ -13,6 +29,10 @@ export async function resolveRepos(options) {
13
29
  });
14
30
  const repos = await listOwnerRepos(options.github, org);
15
31
  repos.forEach((repo) => repoSet.add(repo));
32
+ // Cache the result
33
+ if (cache) {
34
+ cache.upsertRepoList(org, repos);
35
+ }
16
36
  }
17
37
  for (const pattern of options.config.repos ?? []) {
18
38
  if (pattern.includes('*')) {
@@ -20,11 +40,36 @@ export async function resolveRepos(options) {
20
40
  if (!owner) {
21
41
  throw new Error(`Invalid repo pattern: ${pattern}`);
22
42
  }
23
- options.onProgress?.({
24
- phase: 'resolve',
25
- message: `Resolving repos for ${owner}`,
26
- });
27
- const repos = await listOwnerRepos(options.github, owner);
43
+ // Check cache first (unless forceSync)
44
+ let repos;
45
+ if (cache && !forceSync) {
46
+ const cached = cache.getRepoList(owner);
47
+ if (cached && cache.isFresh(cached.fetched_at, cacheTtlHours)) {
48
+ options.onProgress?.({
49
+ phase: 'resolve',
50
+ message: `Using cached repos for ${owner}`,
51
+ });
52
+ repos = cached.repos;
53
+ }
54
+ else {
55
+ options.onProgress?.({
56
+ phase: 'resolve',
57
+ message: `Resolving repos for ${owner}`,
58
+ });
59
+ repos = await listOwnerRepos(options.github, owner);
60
+ cache.upsertRepoList(owner, repos);
61
+ }
62
+ }
63
+ else {
64
+ options.onProgress?.({
65
+ phase: 'resolve',
66
+ message: `Resolving repos for ${owner}`,
67
+ });
68
+ repos = await listOwnerRepos(options.github, owner);
69
+ if (cache) {
70
+ cache.upsertRepoList(owner, repos);
71
+ }
72
+ }
28
73
  const matcher = picomatch(pattern);
29
74
  repos.filter((repo) => matcher(repo)).forEach((repo) => repoSet.add(repo));
30
75
  continue;
package/dist/team.cjs CHANGED
@@ -21,8 +21,19 @@ async function resolveTeamMembers(options) {
21
21
  }
22
22
  }
23
23
  }
24
+ /**
25
+ * Deduplicate usernames case-insensitively.
26
+ * Preserves the first occurrence's casing (typically the canonical GitHub casing).
27
+ */
24
28
  function unique(values) {
25
- return Array.from(new Set(values));
29
+ const seen = new Map();
30
+ for (const value of values) {
31
+ const key = value.toLowerCase();
32
+ if (!seen.has(key)) {
33
+ seen.set(key, value);
34
+ }
35
+ }
36
+ return Array.from(seen.values());
26
37
  }
27
38
  async function listOwnerMembers(github, owner) {
28
39
  try {
package/dist/team.js CHANGED
@@ -18,8 +18,19 @@ export async function resolveTeamMembers(options) {
18
18
  }
19
19
  }
20
20
  }
21
+ /**
22
+ * Deduplicate usernames case-insensitively.
23
+ * Preserves the first occurrence's casing (typically the canonical GitHub casing).
24
+ */
21
25
  function unique(values) {
22
- return Array.from(new Set(values));
26
+ const seen = new Map();
27
+ for (const value of values) {
28
+ const key = value.toLowerCase();
29
+ if (!seen.has(key)) {
30
+ seen.set(key, value);
31
+ }
32
+ }
33
+ return Array.from(seen.values());
23
34
  }
24
35
  async function listOwnerMembers(github, owner) {
25
36
  try {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@0xbigboss/gh-pulse-core",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
+ "license": "MIT",
4
5
  "files": [
5
6
  "dist"
6
7
  ],