@littlebearapps/platform-admin-sdk 2.2.0 → 2.3.1

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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pre-flight check for upgrades — detects potential issues before writing files.
3
+ *
4
+ * Checks for:
5
+ * - Template naming collisions
6
+ * - Placeholder values left in wrangler configs (e.g., "YOUR_DB_ID")
7
+ * - Migration number conflicts
8
+ * - Excluded files from the manifest
9
+ */
10
+ import type { Tier } from './prompts.js';
11
+ export interface CheckResult {
12
+ currentVersion: string;
13
+ targetVersion: string;
14
+ currentTier: string;
15
+ targetTier: string;
16
+ placeholders: string[];
17
+ collisions: string[];
18
+ migrationConflicts: string[];
19
+ excludedFiles: string[];
20
+ ok: boolean;
21
+ }
22
+ /**
23
+ * Run pre-flight checks for an upgrade without modifying any files.
24
+ *
25
+ * @param projectDir - Path to the project directory
26
+ * @param targetTier - Optional tier to upgrade to (defaults to current tier)
27
+ * @returns CheckResult with all detected issues
28
+ */
29
+ export declare function checkUpgrade(projectDir: string, targetTier?: Tier): CheckResult;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pre-flight check for upgrades — detects potential issues before writing files.
3
+ *
4
+ * Checks for:
5
+ * - Template naming collisions
6
+ * - Placeholder values left in wrangler configs (e.g., "YOUR_DB_ID")
7
+ * - Migration number conflicts
8
+ * - Excluded files from the manifest
9
+ */
10
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { detectCollisions, SDK_VERSION, getFilesForTier, isMigrationFile } from './templates.js';
13
+ import { readManifest, MANIFEST_FILENAME } from './manifest.js';
14
+ import { findHighestMigration, getMigrationNumber } from './migrations.js';
15
+ /** Patterns that indicate placeholder values left by scaffolding. */
16
+ const PLACEHOLDER_PATTERNS = [
17
+ /YOUR_DB_ID/,
18
+ /YOUR_KV_ID/,
19
+ /YOUR_QUEUE_ID/,
20
+ /YOUR_R2_BUCKET/,
21
+ /PLACEHOLDER/,
22
+ /your-database-id/,
23
+ /your-kv-namespace-id/,
24
+ /your-queue-id/,
25
+ ];
26
+ /**
27
+ * Run pre-flight checks for an upgrade without modifying any files.
28
+ *
29
+ * @param projectDir - Path to the project directory
30
+ * @param targetTier - Optional tier to upgrade to (defaults to current tier)
31
+ * @returns CheckResult with all detected issues
32
+ */
33
+ export function checkUpgrade(projectDir, targetTier) {
34
+ const manifest = readManifest(projectDir);
35
+ if (!manifest) {
36
+ throw new Error(`No ${MANIFEST_FILENAME} found in ${projectDir}.\n` +
37
+ `Run \`platform-admin-sdk adopt\` first.`);
38
+ }
39
+ const effectiveTier = targetTier ?? manifest.tier;
40
+ const result = {
41
+ currentVersion: manifest.sdkVersion,
42
+ targetVersion: SDK_VERSION,
43
+ currentTier: manifest.tier,
44
+ targetTier: effectiveTier,
45
+ placeholders: [],
46
+ collisions: [],
47
+ migrationConflicts: [],
48
+ excludedFiles: [...(manifest.excludeFromUpgrade ?? [])],
49
+ ok: true,
50
+ };
51
+ // 1. Check for naming collisions
52
+ result.collisions = detectCollisions(effectiveTier);
53
+ // 2. Scan wrangler.*.jsonc files for placeholder values
54
+ if (existsSync(projectDir)) {
55
+ let dirEntries;
56
+ try {
57
+ dirEntries = readdirSync(projectDir);
58
+ }
59
+ catch {
60
+ dirEntries = [];
61
+ }
62
+ const wranglerFiles = dirEntries.filter((f) => f.startsWith('wrangler') && (f.endsWith('.jsonc') || f.endsWith('.json')));
63
+ for (const wf of wranglerFiles) {
64
+ const filePath = join(projectDir, wf);
65
+ const content = readFileSync(filePath, 'utf-8');
66
+ for (const pattern of PLACEHOLDER_PATTERNS) {
67
+ if (pattern.test(content)) {
68
+ result.placeholders.push(`${wf}: contains ${pattern.source}`);
69
+ }
70
+ }
71
+ }
72
+ }
73
+ // 3. Check for migration conflicts
74
+ const migrationsDir = join(projectDir, 'storage/d1/migrations');
75
+ const userHighest = findHighestMigration(migrationsDir);
76
+ const files = getFilesForTier(effectiveTier);
77
+ const migrationFiles = files.filter((f) => isMigrationFile(f));
78
+ // Find new scaffold migrations that would need to be renumbered
79
+ const newScaffoldMigrations = migrationFiles.filter((f) => {
80
+ const num = getMigrationNumber(f.dest);
81
+ return num !== null && num > manifest.highestScaffoldMigration;
82
+ });
83
+ // Check if any new scaffold migration numbers overlap with existing user migrations
84
+ for (const mf of newScaffoldMigrations) {
85
+ const num = getMigrationNumber(mf.dest);
86
+ if (num !== null && num <= userHighest && num > manifest.highestScaffoldMigration) {
87
+ const filename = mf.dest.split('/').pop() ?? mf.dest;
88
+ result.migrationConflicts.push(`${filename} (number ${num}) overlaps with existing user migrations (highest: ${userHighest}) — will be renumbered`);
89
+ }
90
+ }
91
+ // 4. Determine overall ok status
92
+ result.ok =
93
+ result.collisions.length === 0 &&
94
+ result.placeholders.length === 0 &&
95
+ result.migrationConflicts.length === 0;
96
+ return result;
97
+ }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import { collectOptions, isValidTier } from './prompts.js';
24
24
  import { scaffold } from './scaffold.js';
25
25
  import { upgrade } from './upgrade.js';
26
26
  import { adopt } from './adopt.js';
27
+ import { checkUpgrade } from './check-upgrade.js';
27
28
  import { SDK_VERSION } from './templates.js';
28
29
  const BANNER = `
29
30
  ${pc.bold(pc.cyan('Platform Admin SDK'))} — Cloudflare Cost Protection
@@ -109,11 +110,15 @@ const upgradeCmd = new Command('upgrade')
109
110
  });
110
111
  console.log();
111
112
  const total = result.created.length + result.updated.length + result.migrations.length;
112
- if (total === 0 && result.skipped.length === 0) {
113
+ if (total === 0 && result.skipped.length === 0 && result.excluded.length === 0) {
113
114
  console.log(pc.green(' Already up to date.'));
114
115
  }
115
116
  else {
116
- console.log(` ${pc.green(`${result.created.length} created`)}, ${pc.cyan(`${result.updated.length} updated`)}, ${pc.yellow(`${result.skipped.length} skipped`)}, ${pc.green(`${result.migrations.length} migrations`)}`);
117
+ let summary = ` ${pc.green(`${result.created.length} created`)}, ${pc.cyan(`${result.updated.length} updated`)}, ${pc.yellow(`${result.skipped.length} skipped`)}, ${pc.green(`${result.migrations.length} migrations`)}`;
118
+ if (result.excluded.length > 0) {
119
+ summary += `, ${pc.dim(`${result.excluded.length} excluded`)}`;
120
+ }
121
+ console.log(summary);
117
122
  }
118
123
  if (result.removed.length > 0) {
119
124
  console.log(` ${pc.yellow(`${result.removed.length} files removed from SDK (kept on disk)`)}`);
@@ -170,6 +175,55 @@ const adoptCmd = new Command('adopt')
170
175
  console.log(` ${pc.dim('You can now run:')} ${pc.cyan('platform-admin-sdk upgrade')}`);
171
176
  console.log();
172
177
  });
178
+ // --- Check-upgrade command ---
179
+ const checkUpgradeCmd = new Command('check-upgrade')
180
+ .description('Pre-flight check for upgrades — detect issues without writing files')
181
+ .argument('[project-dir]', 'Path to the project directory', '.')
182
+ .option('--tier <tier>', 'Target tier to check against')
183
+ .action(async (projectDirArg, cmdOpts) => {
184
+ if (cmdOpts.tier && !isValidTier(cmdOpts.tier)) {
185
+ console.error(pc.red(` Error: Invalid tier "${cmdOpts.tier}". Must be one of: minimal, standard, full`));
186
+ process.exit(1);
187
+ }
188
+ const projectDir = resolve(process.cwd(), projectDirArg);
189
+ console.log(` ${pc.bold('Checking')}: ${projectDir}`);
190
+ console.log();
191
+ const result = checkUpgrade(projectDir, cmdOpts.tier);
192
+ console.log(` ${pc.bold('Current')}: v${result.currentVersion} (${result.currentTier})`);
193
+ console.log(` ${pc.bold('Target')}: v${result.targetVersion} (${result.targetTier})`);
194
+ console.log();
195
+ if (result.collisions.length > 0) {
196
+ console.log(pc.red(` Collisions (${result.collisions.length}):`));
197
+ for (const c of result.collisions)
198
+ console.log(` - ${c}`);
199
+ console.log();
200
+ }
201
+ if (result.placeholders.length > 0) {
202
+ console.log(pc.yellow(` Placeholders (${result.placeholders.length}):`));
203
+ for (const p of result.placeholders)
204
+ console.log(` - ${p}`);
205
+ console.log();
206
+ }
207
+ if (result.migrationConflicts.length > 0) {
208
+ console.log(pc.yellow(` Migration conflicts (${result.migrationConflicts.length}):`));
209
+ for (const m of result.migrationConflicts)
210
+ console.log(` - ${m}`);
211
+ console.log();
212
+ }
213
+ if (result.excludedFiles.length > 0) {
214
+ console.log(pc.dim(` Excluded files (${result.excludedFiles.length}):`));
215
+ for (const e of result.excludedFiles)
216
+ console.log(` - ${e}`);
217
+ console.log();
218
+ }
219
+ if (result.ok) {
220
+ console.log(pc.green(' All checks passed. Safe to upgrade.'));
221
+ }
222
+ else {
223
+ console.log(pc.yellow(' Issues found. Review before upgrading.'));
224
+ }
225
+ console.log();
226
+ });
173
227
  // --- Main program ---
174
228
  const program = new Command()
175
229
  .name('platform-admin-sdk')
@@ -177,12 +231,13 @@ const program = new Command()
177
231
  .version(SDK_VERSION)
178
232
  .addCommand(scaffoldCmd)
179
233
  .addCommand(upgradeCmd)
180
- .addCommand(adoptCmd);
234
+ .addCommand(adoptCmd)
235
+ .addCommand(checkUpgradeCmd);
181
236
  async function main() {
182
237
  console.log(BANNER);
183
238
  // Backward compat: if first arg isn't a known subcommand, treat it as `scaffold <arg>`
184
239
  const args = process.argv.slice(2);
185
- const subcommands = ['scaffold', 'upgrade', 'adopt', 'help', '--help', '-h', '--version', '-V'];
240
+ const subcommands = ['scaffold', 'upgrade', 'adopt', 'check-upgrade', 'help', '--help', '-h', '--version', '-V'];
186
241
  if (args.length > 0 && !subcommands.includes(args[0])) {
187
242
  // Inject 'scaffold' as the subcommand
188
243
  process.argv.splice(2, 0, 'scaffold');
@@ -28,6 +28,8 @@ export interface ScaffoldManifest {
28
28
  files: Record<string, string>;
29
29
  /** Highest migration number owned by the scaffolder (user migrations are higher). */
30
30
  highestScaffoldMigration: number;
31
+ /** Relative file paths to skip during upgrade (user-managed files). */
32
+ excludeFromUpgrade?: string[];
31
33
  }
32
34
  /** SHA-256 hash of file content. */
33
35
  export declare function hashContent(content: string): string;
package/dist/scaffold.js CHANGED
@@ -6,7 +6,7 @@ import { resolve, dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import Handlebars from 'handlebars';
8
8
  import pc from 'picocolors';
9
- import { getFilesForTier, SDK_VERSION } from './templates.js';
9
+ import { getFilesForTier, SDK_VERSION, detectCollisions } from './templates.js';
10
10
  import { hashContent, buildManifest, writeManifest, MANIFEST_FILENAME } from './manifest.js';
11
11
  import { findHighestMigration } from './migrations.js';
12
12
  const __filename = fileURLToPath(import.meta.url);
@@ -35,6 +35,10 @@ export async function scaffold(options, outputDir) {
35
35
  }
36
36
  throw new Error(`Directory already exists: ${outputDir}`);
37
37
  }
38
+ const collisions = detectCollisions(options.tier);
39
+ if (collisions.length > 0) {
40
+ throw new Error(`Template naming collisions detected:\n${collisions.map((c) => ` - ${c}`).join('\n')}`);
41
+ }
38
42
  const templatesDir = getTemplatesDir();
39
43
  const files = getFilesForTier(options.tier);
40
44
  const context = {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Tier } from './prompts.js';
8
8
  /** Single source of truth for the SDK version. */
9
- export declare const SDK_VERSION = "2.2.0";
9
+ export declare const SDK_VERSION = "2.3.0";
10
10
  /** Returns true if `to` is the same or higher tier than `from`. */
11
11
  export declare function isTierUpgradeOrSame(from: Tier, to: Tier): boolean;
12
12
  /** Check if a template file is a numbered migration (not seed.sql). */
@@ -19,4 +19,9 @@ export interface TemplateFile {
19
19
  /** Whether this file uses Handlebars templating */
20
20
  template: boolean;
21
21
  }
22
+ /**
23
+ * Detect naming collisions where two template files resolve to the same destination path.
24
+ * Returns an array of collision descriptions (empty = no collisions).
25
+ */
26
+ export declare function detectCollisions(tier: Tier): string[];
22
27
  export declare function getFilesForTier(tier: Tier): TemplateFile[];
package/dist/templates.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * All other files are copied verbatim.
6
6
  */
7
7
  /** Single source of truth for the SDK version. */
8
- export const SDK_VERSION = '2.2.0';
8
+ export const SDK_VERSION = '2.3.0';
9
9
  /** Tier ordering for upgrade validation. */
10
10
  const TIER_ORDER = { minimal: 0, standard: 1, full: 2 };
11
11
  /** Returns true if `to` is the same or higher tier than `from`. */
@@ -566,6 +566,26 @@ const FULL_FILES = [
566
566
  { src: 'full/tests/integration/r2-archive.test.ts', dest: 'tests/integration/r2-archive.test.ts', template: false },
567
567
  { src: 'full/tests/integration/feedback-schema.test.ts', dest: 'tests/integration/feedback-schema.test.ts', template: false },
568
568
  ];
569
+ /**
570
+ * Detect naming collisions where two template files resolve to the same destination path.
571
+ * Returns an array of collision descriptions (empty = no collisions).
572
+ */
573
+ export function detectCollisions(tier) {
574
+ const files = getFilesForTier(tier);
575
+ const seen = new Map();
576
+ const collisions = [];
577
+ for (const file of files) {
578
+ const dest = file.dest;
579
+ const existing = seen.get(dest);
580
+ if (existing) {
581
+ collisions.push(`"${existing}" and "${file.src}" both resolve to "${dest}"`);
582
+ }
583
+ else {
584
+ seen.set(dest, file.src);
585
+ }
586
+ }
587
+ return collisions;
588
+ }
569
589
  export function getFilesForTier(tier) {
570
590
  const files = [...SHARED_FILES];
571
591
  if (tier === 'standard' || tier === 'full') {
package/dist/upgrade.d.ts CHANGED
@@ -20,5 +20,6 @@ export interface UpgradeResult {
20
20
  skipped: string[];
21
21
  removed: string[];
22
22
  migrations: string[];
23
+ excluded: string[];
23
24
  }
24
25
  export declare function upgrade(projectDir: string, options?: UpgradeOptions): Promise<UpgradeResult>;
package/dist/upgrade.js CHANGED
@@ -14,7 +14,7 @@ import { resolve, dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import Handlebars from 'handlebars';
16
16
  import pc from 'picocolors';
17
- import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame } from './templates.js';
17
+ import { getFilesForTier, SDK_VERSION, isMigrationFile, isTierUpgradeOrSame, detectCollisions } from './templates.js';
18
18
  import { readManifest, writeManifest, buildManifest, hashContent, MANIFEST_FILENAME, } from './manifest.js';
19
19
  import { findHighestMigration, getMigrationNumber, planMigrations } from './migrations.js';
20
20
  const __filename = fileURLToPath(import.meta.url);
@@ -46,7 +46,11 @@ export async function upgrade(projectDir, options = {}) {
46
46
  }
47
47
  if (manifest.sdkVersion === SDK_VERSION && manifest.tier === targetTier) {
48
48
  console.log(pc.green(` Already up to date (SDK ${SDK_VERSION}, tier ${targetTier}).`));
49
- return { created: [], updated: [], skipped: [], removed: [], migrations: [] };
49
+ return { created: [], updated: [], skipped: [], removed: [], migrations: [], excluded: [] };
50
+ }
51
+ const collisions = detectCollisions(targetTier);
52
+ if (collisions.length > 0) {
53
+ throw new Error(`Template naming collisions detected:\n${collisions.map((c) => ` - ${c}`).join('\n')}`);
50
54
  }
51
55
  const templatesDir = getTemplatesDir();
52
56
  const files = getFilesForTier(targetTier);
@@ -59,12 +63,14 @@ export async function upgrade(projectDir, options = {}) {
59
63
  defaultAssignee: manifest.context.defaultAssignee,
60
64
  sdkVersion: SDK_VERSION,
61
65
  };
66
+ const excludeSet = new Set(manifest.excludeFromUpgrade ?? []);
62
67
  const result = {
63
68
  created: [],
64
69
  updated: [],
65
70
  skipped: [],
66
71
  removed: [],
67
72
  migrations: [],
73
+ excluded: [],
68
74
  };
69
75
  // Separate regular files from migrations
70
76
  const regularFiles = files.filter((f) => !isMigrationFile(f));
@@ -75,6 +81,16 @@ export async function upgrade(projectDir, options = {}) {
75
81
  const srcPath = join(templatesDir, file.src);
76
82
  const destRelative = renderString(file.dest, context);
77
83
  const destPath = join(projectDir, destRelative);
84
+ if (excludeSet.has(destRelative)) {
85
+ console.log(` ${pc.dim('exclude')} ${destRelative} ${pc.dim('(in excludeFromUpgrade)')}`);
86
+ result.excluded.push(destRelative);
87
+ // Preserve existing hash in new manifest if file is on disk
88
+ if (existsSync(destPath)) {
89
+ const diskContent = readFileSync(destPath, 'utf-8');
90
+ newFileHashes[destRelative] = hashContent(diskContent);
91
+ }
92
+ continue;
93
+ }
78
94
  if (!existsSync(srcPath))
79
95
  continue;
80
96
  const raw = readFileSync(srcPath, 'utf-8');
@@ -174,6 +190,9 @@ export async function upgrade(projectDir, options = {}) {
174
190
  // --- Write updated manifest ---
175
191
  if (!options.dryRun) {
176
192
  const newManifest = buildManifest(SDK_VERSION, targetTier, manifest.context, newFileHashes, highestScaffoldMig);
193
+ if (manifest.excludeFromUpgrade && manifest.excludeFromUpgrade.length > 0) {
194
+ newManifest.excludeFromUpgrade = manifest.excludeFromUpgrade;
195
+ }
177
196
  writeManifest(projectDir, newManifest);
178
197
  }
179
198
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/platform-admin-sdk",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Platform Admin SDK — scaffold backend infrastructure with workers, circuit breakers, and cost protection for Cloudflare",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1187,10 +1187,10 @@ async function findExistingIssue(
1187
1187
  const alertNumber = alert.metadata?.alert_number as number;
1188
1188
  const ruleId = alert.metadata?.rule_id as string;
1189
1189
 
1190
- const query = `repo:${owner}/${repo} is:issue is:open label:codeql "${ruleId}" in:title "${alertNumber}" in:body`;
1191
-
1190
+ // Use Issues List API (not Search API) more reliable with GitHub App tokens.
1191
+ const labelParam = encodeURIComponent('codeql');
1192
1192
  const response = await fetch(
1193
- `https://api.github.com/search/issues?q=${encodeURIComponent(query)}`,
1193
+ `https://api.github.com/repos/${owner}/${repo}/issues?labels=${labelParam}&state=open&per_page=10&sort=created&direction=desc`,
1194
1194
  {
1195
1195
  headers: {
1196
1196
  Authorization: `Bearer ${env.GITHUB_TOKEN}`,
@@ -1201,16 +1201,15 @@ async function findExistingIssue(
1201
1201
  );
1202
1202
 
1203
1203
  if (!response.ok) {
1204
- log.error('Failed to search for existing issues', { status: response.status });
1204
+ log.error('Failed to list issues by label', { status: response.status });
1205
1205
  return null;
1206
1206
  }
1207
1207
 
1208
- const data = (await response.json()) as {
1209
- items?: Array<{ number: number; title: string; body: string }>;
1210
- };
1208
+ const data = (await response.json()) as
1209
+ Array<{ number: number; title: string; body: string }>;
1211
1210
 
1212
1211
  // Check if any results match this alert number
1213
- const match = data.items?.find((issue) =>
1212
+ const match = data.find((issue) =>
1214
1213
  issue.body?.includes(`Alert Number**: #${alertNumber}`)
1215
1214
  );
1216
1215
 
@@ -200,16 +200,13 @@ export async function processEmailHealthAlerts(
200
200
  try {
201
201
  const issueTitle = `Email Health: ${brandName} ${failure.check_type} failing`;
202
202
 
203
- // Search for an existing OPEN issue before creating a new one.
204
- // This prevents daily duplicate issues when a problem persists across days.
205
- const openIssues = await github.searchIssues(
206
- owner,
207
- repo,
208
- `"${issueTitle}" is:open label:cf:email-health`
209
- );
203
+ // List existing OPEN email health issues before creating a new one.
204
+ // Uses Issues List API (not Search API) more reliable with GitHub App tokens.
205
+ const openIssues = await github.listIssuesByLabel(owner, repo, ['cf:email-health'], 'open');
206
+ const matchingIssue = openIssues.find((issue) => issue.title === issueTitle);
210
207
 
211
- if (openIssues.length > 0) {
212
- const existing = openIssues[0];
208
+ if (matchingIssue) {
209
+ const existing = matchingIssue;
213
210
  await github.addComment(
214
211
  owner,
215
212
  repo,
@@ -237,16 +237,15 @@ export async function processGapAlert(
237
237
  const github = new GitHubClient(env);
238
238
 
239
239
  try {
240
- // Search for an existing OPEN gap alert issue before creating a new one.
241
- // This prevents daily duplicate issues when coverage stays below threshold.
242
- const openIssues = await github.searchIssues(
243
- owner,
244
- repo,
245
- `"Data Coverage Gap: ${event.project}" is:open label:cf:gap-alert`
240
+ // List existing OPEN gap alert issues before creating a new one.
241
+ // Uses Issues List API (not Search API) more reliable with GitHub App tokens.
242
+ const openIssues = await github.listIssuesByLabel(owner, repo, ['cf:gap-alert'], 'open');
243
+ const matchingIssue = openIssues.find((issue) =>
244
+ issue.title.includes(`Data Coverage Gap: ${event.project}`)
246
245
  );
247
246
 
248
- if (openIssues.length > 0) {
249
- const existing = openIssues[0];
247
+ if (matchingIssue) {
248
+ const existing = matchingIssue;
250
249
  await github.addComment(
251
250
  owner,
252
251
  repo,
@@ -277,6 +277,32 @@ export class GitHubClient {
277
277
  return this.request('GET', `/repos/${owner}/${repo}/issues/${issueNumber}`);
278
278
  }
279
279
 
280
+ /**
281
+ * List issues filtered by labels using the Issues List API.
282
+ * More reliable than Search API with GitHub App installation tokens.
283
+ * @see https://docs.github.com/en/rest/issues/issues#list-repository-issues
284
+ */
285
+ async listIssuesByLabel(
286
+ owner: string,
287
+ repo: string,
288
+ labels: string[],
289
+ state: 'open' | 'closed' = 'open'
290
+ ): Promise<
291
+ Array<{
292
+ number: number;
293
+ state: string;
294
+ title: string;
295
+ body: string | null;
296
+ labels: Array<{ name: string }>;
297
+ }>
298
+ > {
299
+ const labelParam = labels.join(',');
300
+ return this.request(
301
+ 'GET',
302
+ `/repos/${owner}/${repo}/issues?labels=${encodeURIComponent(labelParam)}&state=${state}&per_page=10&sort=created&direction=desc`
303
+ );
304
+ }
305
+
280
306
  /**
281
307
  * Search for issues using GitHub's search API.
282
308
  * Retries once on 403 (rate limit) with a 1s delay.