@postman-cse/onboarding-azure-spec-discovery 1.1.0 → 1.2.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/README.md CHANGED
@@ -11,7 +11,7 @@ The action resolves the best specification source for the current repository in
11
11
  3. **App Service API definition** — a site whose `apiDefinition.url` points at a reachable OpenAPI document.
12
12
  4. **Local Azure IaC** — OpenAPI content embedded in ARM/Bicep templates or referenced by `azure.yaml` in the repository.
13
13
 
14
- When several Azure candidates match, a four-tier narrowing pipeline (IaC fingerprint, resource-group correlation, `postman:*` tag prefilter, naming heuristic) orders them, and genuinely ambiguous results surface as a ranked GitHub Step Summary table instead of a guess.
14
+ When several Azure candidates match, a four-tier narrowing pipeline (IaC fingerprint, resource-group correlation, repo-tag prefilter, naming heuristic) orders them, and genuinely ambiguous results surface as a ranked GitHub Step Summary table instead of a guess. The tag prefilter selects outright when exactly one candidate carries a select-grade repo tag for the calling repository: canonical `postman:repo=<org>/<repo>`, the Fox-style `GithubOrg`/`GithubRepo` pair, or any extra keys supplied via the CLI-only `--repo-tag-keys-json` flag. Tag names and values compare case-insensitively (a trailing `.git` is tolerated), and when enumerated candidates carry no matching tags a single targeted Resource Graph tag lookup runs as a fallback — so a gateway tagged with its owning repo resolves automatically, per service repo, with zero further configuration.
15
15
 
16
16
  ## Auth and Postman handoff
17
17
 
package/dist/cli.cjs CHANGED
@@ -188459,6 +188459,78 @@ function toAmbiguousViews(ranked) {
188459
188459
  }));
188460
188460
  }
188461
188461
 
188462
+ // src/lib/resolve/resource-graph-query.ts
188463
+ function escapeKqlString(value) {
188464
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
188465
+ }
188466
+ var CANDIDATE_TYPES = [
188467
+ "microsoft.apimanagement/service/apis",
188468
+ "microsoft.apimanagement/service/workspaces/apis",
188469
+ "microsoft.web/sites",
188470
+ "microsoft.web/customapis",
188471
+ "microsoft.logic/workflows",
188472
+ "microsoft.resources/templatespecs/versions",
188473
+ "microsoft.eventgrid/topics",
188474
+ "microsoft.eventgrid/domains",
188475
+ "microsoft.eventgrid/systemtopics",
188476
+ "microsoft.servicebus/namespaces/topics"
188477
+ ];
188478
+ function buildCandidateQuery(resourceGroup) {
188479
+ const typeFilter = CANDIDATE_TYPES.map((type) => `'${type}'`).join(", ");
188480
+ const lines = [
188481
+ "Resources",
188482
+ `| where type in~ (${typeFilter})`
188483
+ ];
188484
+ const trimmedGroup = (resourceGroup ?? "").trim();
188485
+ if (trimmedGroup) {
188486
+ lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
188487
+ }
188488
+ lines.push(
188489
+ "| extend apiType=tostring(properties.apiType), isCurrent=tobool(properties.isCurrent), apiRevision=tostring(properties.apiRevision), apiVersionSetId=tostring(properties.apiVersionSetId), apiDefinitionUrl=tostring(properties.siteConfig.apiDefinition.url)",
188490
+ "| project id, name, type, resourceGroup, tags, apiType, isCurrent, apiRevision, apiVersionSetId, apiDefinitionUrl"
188491
+ );
188492
+ return lines.join("\n");
188493
+ }
188494
+ function tagKeyCaseVariants(key) {
188495
+ const lower = key.toLowerCase();
188496
+ const upperFirst = lower.charAt(0).toUpperCase() + lower.slice(1);
188497
+ const pascalized = lower.split(/[:_-]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
188498
+ return [.../* @__PURE__ */ new Set([key, lower, upperFirst, pascalized])];
188499
+ }
188500
+ var FOX_ORG_KEY_VARIANTS = ["GithubOrg", "githuborg", "Githuborg", "GitHubOrg"];
188501
+ var FOX_REPO_KEY_VARIANTS = ["GithubRepo", "githubrepo", "Githubrepo", "GitHubRepo"];
188502
+ function buildRepoTagLookupQuery(repoSlug, repoTagKeys = [], resourceGroup) {
188503
+ const slug = repoSlug.trim().replace(/\.git$/i, "");
188504
+ const slugEscaped = escapeKqlString(slug);
188505
+ const clauses = [];
188506
+ for (const key of ["postman:repo", ...repoTagKeys]) {
188507
+ for (const variant of tagKeyCaseVariants(key)) {
188508
+ clauses.push(`tostring(tags['${escapeKqlString(variant)}']) =~ '${slugEscaped}'`);
188509
+ clauses.push(`tostring(tags['${escapeKqlString(variant)}']) =~ '${slugEscaped}.git'`);
188510
+ }
188511
+ }
188512
+ const [org, ...repoParts] = slug.split("/");
188513
+ const repoName = repoParts.join("/");
188514
+ if (org && repoName) {
188515
+ const orgEscaped = escapeKqlString(org);
188516
+ const repoEscaped = escapeKqlString(repoName);
188517
+ for (const orgVariant of FOX_ORG_KEY_VARIANTS) {
188518
+ for (const repoVariant of FOX_REPO_KEY_VARIANTS) {
188519
+ clauses.push(
188520
+ `(tostring(tags['${orgVariant}']) =~ '${orgEscaped}' and tostring(tags['${repoVariant}']) =~ '${repoEscaped}')`
188521
+ );
188522
+ }
188523
+ }
188524
+ }
188525
+ const lines = ["Resources", `| where ${clauses.join(" or ")}`];
188526
+ const trimmedGroup = (resourceGroup ?? "").trim();
188527
+ if (trimmedGroup) {
188528
+ lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
188529
+ }
188530
+ lines.push("| project id, name, type, resourceGroup, tags");
188531
+ return lines.join("\n");
188532
+ }
188533
+
188462
188534
  // src/lib/resolve/narrowing-pipeline.ts
188463
188535
  function slugifyRepoName(repoSlug) {
188464
188536
  if (!repoSlug) return [];
@@ -188494,30 +188566,54 @@ function tierResourceGroupCorrelation(candidates, ctx) {
188494
188566
  };
188495
188567
  }
188496
188568
  var CANONICAL_REPO_TAG = "postman:repo";
188569
+ var FOX_ORG_TAG = "githuborg";
188570
+ var FOX_REPO_TAG = "githubrepo";
188497
188571
  var GENERIC_TAG_KEYS = ["repo", "repository", "service", "github:repository"];
188572
+ function normalizeTagBag(tags2) {
188573
+ const normalized = {};
188574
+ for (const [key, value] of Object.entries(tags2 ?? {})) {
188575
+ normalized[key.toLowerCase()] = (value ?? "").trim();
188576
+ }
188577
+ return normalized;
188578
+ }
188579
+ function slugEquals(a, b) {
188580
+ const strip = (value) => value.trim().replace(/\.git$/i, "").toLowerCase();
188581
+ const left = strip(a);
188582
+ return left.length > 0 && left === strip(b);
188583
+ }
188498
188584
  async function tierTagPreFilter(candidates, ctx) {
188499
188585
  if (!ctx.repoSlug) return void 0;
188500
- const exactMatches = candidates.filter((candidate) => (candidate.tags?.[CANONICAL_REPO_TAG] ?? "") === ctx.repoSlug);
188501
- const uniqueExact = [...new Set(exactMatches.map((candidate) => candidate.id))];
188586
+ const repoSlug = ctx.repoSlug;
188587
+ const selectKeys = [CANONICAL_REPO_TAG, ...(ctx.repoTagKeys ?? []).map((key) => key.toLowerCase())];
188588
+ const selectMatches = candidates.filter((candidate) => {
188589
+ const tags2 = normalizeTagBag(candidate.tags);
188590
+ if (selectKeys.some((key) => slugEquals(tags2[key] ?? "", repoSlug))) return true;
188591
+ const org = tags2[FOX_ORG_TAG] ?? "";
188592
+ const repo = tags2[FOX_REPO_TAG] ?? "";
188593
+ return org.length > 0 && repo.length > 0 && slugEquals(`${org}/${repo}`, repoSlug);
188594
+ });
188595
+ const uniqueExact = [...new Set(selectMatches.map((candidate) => candidate.id))];
188502
188596
  if (uniqueExact.length === 1) {
188503
188597
  return {
188504
188598
  ids: uniqueExact,
188505
188599
  selectId: uniqueExact[0],
188506
- evidence: [`Exactly one API tagged ${CANONICAL_REPO_TAG}=${ctx.repoSlug}`]
188600
+ evidence: [`Exactly one API carries a select-grade repo tag matching ${repoSlug}`]
188507
188601
  };
188508
188602
  }
188509
188603
  if (uniqueExact.length > 1) {
188510
188604
  return {
188511
188605
  ids: uniqueExact,
188512
- evidence: [`Found ${uniqueExact.length} APIs tagged ${CANONICAL_REPO_TAG}=${ctx.repoSlug}`]
188606
+ evidence: [`Found ${uniqueExact.length} APIs with select-grade repo tags matching ${repoSlug}`]
188513
188607
  };
188514
188608
  }
188515
- const repoName = ctx.repoSlug.split("/").pop()?.trim();
188609
+ const graphHit = await tierTagGraphFallback(candidates, ctx);
188610
+ if (graphHit) return graphHit;
188611
+ const repoName = repoSlug.split("/").pop()?.trim();
188516
188612
  const genericMatches = candidates.filter((candidate) => {
188517
- const tags2 = candidate.tags ?? {};
188613
+ const tags2 = normalizeTagBag(candidate.tags);
188518
188614
  return GENERIC_TAG_KEYS.some((key) => {
188519
188615
  const value = tags2[key] ?? "";
188520
- return value === ctx.repoSlug || repoName !== void 0 && value === repoName;
188616
+ return slugEquals(value, repoSlug) || repoName !== void 0 && slugEquals(value, repoName);
188521
188617
  });
188522
188618
  });
188523
188619
  if (genericMatches.length > 0) {
@@ -188528,6 +188624,41 @@ async function tierTagPreFilter(candidates, ctx) {
188528
188624
  }
188529
188625
  return void 0;
188530
188626
  }
188627
+ async function tierTagGraphFallback(candidates, ctx) {
188628
+ if (!ctx.resourceGraphClient || !ctx.subscriptionId || !ctx.repoSlug) return void 0;
188629
+ let rows;
188630
+ try {
188631
+ rows = await ctx.resourceGraphClient.queryResources(
188632
+ ctx.subscriptionId,
188633
+ buildRepoTagLookupQuery(ctx.repoSlug, ctx.repoTagKeys ?? [], ctx.resourceGroup)
188634
+ );
188635
+ } catch {
188636
+ return void 0;
188637
+ }
188638
+ if (rows.length === 0) return void 0;
188639
+ const byLowerId = new Map(candidates.map((candidate) => [candidate.id.toLowerCase(), candidate.id]));
188640
+ const matched = [];
188641
+ const seen = /* @__PURE__ */ new Set();
188642
+ for (const row of rows) {
188643
+ const canonical = byLowerId.get(row.id.toLowerCase());
188644
+ if (canonical !== void 0 && !seen.has(canonical)) {
188645
+ seen.add(canonical);
188646
+ matched.push(canonical);
188647
+ }
188648
+ }
188649
+ if (matched.length === 0) return void 0;
188650
+ if (matched.length === 1) {
188651
+ return {
188652
+ ids: matched,
188653
+ selectId: matched[0],
188654
+ evidence: [`Resource Graph tag lookup matched exactly one enumerated candidate for ${ctx.repoSlug}`]
188655
+ };
188656
+ }
188657
+ return {
188658
+ ids: matched,
188659
+ evidence: [`Resource Graph tag lookup matched ${matched.length} enumerated candidate(s) for ${ctx.repoSlug}`]
188660
+ };
188661
+ }
188531
188662
  function tierNamingHeuristic(candidates, ctx) {
188532
188663
  const slugs = slugifyRepoName(ctx.repoSlug);
188533
188664
  if (slugs.length === 0) return void 0;
@@ -188577,39 +188708,6 @@ async function runNarrowingPipeline(ctx, allCandidates) {
188577
188708
  return void 0;
188578
188709
  }
188579
188710
 
188580
- // src/lib/resolve/resource-graph-query.ts
188581
- function escapeKqlString(value) {
188582
- return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
188583
- }
188584
- var CANDIDATE_TYPES = [
188585
- "microsoft.apimanagement/service/apis",
188586
- "microsoft.apimanagement/service/workspaces/apis",
188587
- "microsoft.web/sites",
188588
- "microsoft.web/customapis",
188589
- "microsoft.logic/workflows",
188590
- "microsoft.resources/templatespecs/versions",
188591
- "microsoft.eventgrid/topics",
188592
- "microsoft.eventgrid/domains",
188593
- "microsoft.eventgrid/systemtopics",
188594
- "microsoft.servicebus/namespaces/topics"
188595
- ];
188596
- function buildCandidateQuery(resourceGroup) {
188597
- const typeFilter = CANDIDATE_TYPES.map((type) => `'${type}'`).join(", ");
188598
- const lines = [
188599
- "Resources",
188600
- `| where type in~ (${typeFilter})`
188601
- ];
188602
- const trimmedGroup = (resourceGroup ?? "").trim();
188603
- if (trimmedGroup) {
188604
- lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
188605
- }
188606
- lines.push(
188607
- "| extend apiType=tostring(properties.apiType), isCurrent=tobool(properties.isCurrent), apiRevision=tostring(properties.apiRevision), apiVersionSetId=tostring(properties.apiVersionSetId), apiDefinitionUrl=tostring(properties.siteConfig.apiDefinition.url)",
188608
- "| project id, name, type, resourceGroup, tags, apiType, isCurrent, apiRevision, apiVersionSetId, apiDefinitionUrl"
188609
- );
188610
- return lines.join("\n");
188611
- }
188612
-
188613
188711
  // src/lib/estate/enumerate.ts
188614
188712
  var SLUG_TAG_KEYS = ["postman:repo", "github:repository", "repo", "repository"];
188615
188713
  var ORG_TAG_KEY = "githuborg";
@@ -190022,6 +190120,7 @@ function resolveInputs(env = process.env) {
190022
190120
  const expectedApiIds = [apiId2, ...parseStringArrayJson(expectedApiIdsRaw, "expected-api-ids-json")].filter(
190023
190121
  (value) => Boolean(value)
190024
190122
  );
190123
+ const repoTagKeysRaw = getInput("repo-tag-keys-json", env) ?? "[]";
190025
190124
  return {
190026
190125
  mode,
190027
190126
  subscriptionId: subscriptionId3,
@@ -190033,6 +190132,7 @@ function resolveInputs(env = process.env) {
190033
190132
  expectedApiIds: [...new Set(expectedApiIds)],
190034
190133
  apiFilter,
190035
190134
  serviceMapping: parseServiceMapping(serviceMappingRaw),
190135
+ repoTagKeys: [...new Set(parseStringArrayJson(repoTagKeysRaw, "repo-tag-keys-json").map((key) => key.toLowerCase()))],
190036
190136
  outputDir,
190037
190137
  maxCandidates: parseBoundedInteger(getInput("max-candidates", env), "max-candidates", 50, 1, 1e4),
190038
190138
  dryRun: parseBoolean(getInput("dry-run", env), "dry-run", false),
@@ -190438,8 +190538,11 @@ async function runResolveOne(inputs, dependencies) {
190438
190538
  {
190439
190539
  repoSlug: inputs.repoContext.repoSlug,
190440
190540
  subscriptionId: subscriptionId3,
190541
+ resourceGroup: inputs.resourceGroup,
190542
+ repoTagKeys: inputs.repoTagKeys,
190441
190543
  serviceHints: signals.serviceHints,
190442
- signals
190544
+ signals,
190545
+ resourceGraphClient: dependencies.createResourceGraphClient?.()
190443
190546
  },
190444
190547
  enumerated.map((candidate) => ({
190445
190548
  id: candidate.id,
@@ -190551,8 +190654,11 @@ async function runDiscoverMany(inputs, dependencies) {
190551
190654
  {
190552
190655
  repoSlug: inputs.repoContext.repoSlug,
190553
190656
  subscriptionId: subscriptionId3,
190657
+ resourceGroup: inputs.resourceGroup,
190658
+ repoTagKeys: inputs.repoTagKeys,
190554
190659
  serviceHints: signals.serviceHints,
190555
- signals
190660
+ signals,
190661
+ resourceGraphClient: dependencies.createResourceGraphClient?.()
190556
190662
  },
190557
190663
  enumerated.map((candidate) => ({
190558
190664
  id: candidate.id,
@@ -190787,6 +190893,7 @@ var CLI_INPUT_NAMES = [
190787
190893
  "expected-api-ids-json",
190788
190894
  "api-filter",
190789
190895
  "service-mapping-json",
190896
+ "repo-tag-keys-json",
190790
190897
  "output-dir",
190791
190898
  "max-candidates",
190792
190899
  "dry-run",
package/dist/index.cjs CHANGED
@@ -191045,6 +191045,78 @@ function toAmbiguousViews(ranked) {
191045
191045
  }));
191046
191046
  }
191047
191047
 
191048
+ // src/lib/resolve/resource-graph-query.ts
191049
+ function escapeKqlString(value) {
191050
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
191051
+ }
191052
+ var CANDIDATE_TYPES = [
191053
+ "microsoft.apimanagement/service/apis",
191054
+ "microsoft.apimanagement/service/workspaces/apis",
191055
+ "microsoft.web/sites",
191056
+ "microsoft.web/customapis",
191057
+ "microsoft.logic/workflows",
191058
+ "microsoft.resources/templatespecs/versions",
191059
+ "microsoft.eventgrid/topics",
191060
+ "microsoft.eventgrid/domains",
191061
+ "microsoft.eventgrid/systemtopics",
191062
+ "microsoft.servicebus/namespaces/topics"
191063
+ ];
191064
+ function buildCandidateQuery(resourceGroup) {
191065
+ const typeFilter = CANDIDATE_TYPES.map((type) => `'${type}'`).join(", ");
191066
+ const lines = [
191067
+ "Resources",
191068
+ `| where type in~ (${typeFilter})`
191069
+ ];
191070
+ const trimmedGroup = (resourceGroup ?? "").trim();
191071
+ if (trimmedGroup) {
191072
+ lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
191073
+ }
191074
+ lines.push(
191075
+ "| extend apiType=tostring(properties.apiType), isCurrent=tobool(properties.isCurrent), apiRevision=tostring(properties.apiRevision), apiVersionSetId=tostring(properties.apiVersionSetId), apiDefinitionUrl=tostring(properties.siteConfig.apiDefinition.url)",
191076
+ "| project id, name, type, resourceGroup, tags, apiType, isCurrent, apiRevision, apiVersionSetId, apiDefinitionUrl"
191077
+ );
191078
+ return lines.join("\n");
191079
+ }
191080
+ function tagKeyCaseVariants(key) {
191081
+ const lower = key.toLowerCase();
191082
+ const upperFirst = lower.charAt(0).toUpperCase() + lower.slice(1);
191083
+ const pascalized = lower.split(/[:_-]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
191084
+ return [.../* @__PURE__ */ new Set([key, lower, upperFirst, pascalized])];
191085
+ }
191086
+ var FOX_ORG_KEY_VARIANTS = ["GithubOrg", "githuborg", "Githuborg", "GitHubOrg"];
191087
+ var FOX_REPO_KEY_VARIANTS = ["GithubRepo", "githubrepo", "Githubrepo", "GitHubRepo"];
191088
+ function buildRepoTagLookupQuery(repoSlug, repoTagKeys = [], resourceGroup) {
191089
+ const slug = repoSlug.trim().replace(/\.git$/i, "");
191090
+ const slugEscaped = escapeKqlString(slug);
191091
+ const clauses = [];
191092
+ for (const key of ["postman:repo", ...repoTagKeys]) {
191093
+ for (const variant of tagKeyCaseVariants(key)) {
191094
+ clauses.push(`tostring(tags['${escapeKqlString(variant)}']) =~ '${slugEscaped}'`);
191095
+ clauses.push(`tostring(tags['${escapeKqlString(variant)}']) =~ '${slugEscaped}.git'`);
191096
+ }
191097
+ }
191098
+ const [org, ...repoParts] = slug.split("/");
191099
+ const repoName = repoParts.join("/");
191100
+ if (org && repoName) {
191101
+ const orgEscaped = escapeKqlString(org);
191102
+ const repoEscaped = escapeKqlString(repoName);
191103
+ for (const orgVariant of FOX_ORG_KEY_VARIANTS) {
191104
+ for (const repoVariant of FOX_REPO_KEY_VARIANTS) {
191105
+ clauses.push(
191106
+ `(tostring(tags['${orgVariant}']) =~ '${orgEscaped}' and tostring(tags['${repoVariant}']) =~ '${repoEscaped}')`
191107
+ );
191108
+ }
191109
+ }
191110
+ }
191111
+ const lines = ["Resources", `| where ${clauses.join(" or ")}`];
191112
+ const trimmedGroup = (resourceGroup ?? "").trim();
191113
+ if (trimmedGroup) {
191114
+ lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
191115
+ }
191116
+ lines.push("| project id, name, type, resourceGroup, tags");
191117
+ return lines.join("\n");
191118
+ }
191119
+
191048
191120
  // src/lib/resolve/narrowing-pipeline.ts
191049
191121
  function slugifyRepoName(repoSlug) {
191050
191122
  if (!repoSlug) return [];
@@ -191080,30 +191152,54 @@ function tierResourceGroupCorrelation(candidates, ctx) {
191080
191152
  };
191081
191153
  }
191082
191154
  var CANONICAL_REPO_TAG = "postman:repo";
191155
+ var FOX_ORG_TAG = "githuborg";
191156
+ var FOX_REPO_TAG = "githubrepo";
191083
191157
  var GENERIC_TAG_KEYS = ["repo", "repository", "service", "github:repository"];
191158
+ function normalizeTagBag(tags2) {
191159
+ const normalized = {};
191160
+ for (const [key, value] of Object.entries(tags2 ?? {})) {
191161
+ normalized[key.toLowerCase()] = (value ?? "").trim();
191162
+ }
191163
+ return normalized;
191164
+ }
191165
+ function slugEquals(a, b) {
191166
+ const strip = (value) => value.trim().replace(/\.git$/i, "").toLowerCase();
191167
+ const left = strip(a);
191168
+ return left.length > 0 && left === strip(b);
191169
+ }
191084
191170
  async function tierTagPreFilter(candidates, ctx) {
191085
191171
  if (!ctx.repoSlug) return void 0;
191086
- const exactMatches = candidates.filter((candidate) => (candidate.tags?.[CANONICAL_REPO_TAG] ?? "") === ctx.repoSlug);
191087
- const uniqueExact = [...new Set(exactMatches.map((candidate) => candidate.id))];
191172
+ const repoSlug = ctx.repoSlug;
191173
+ const selectKeys = [CANONICAL_REPO_TAG, ...(ctx.repoTagKeys ?? []).map((key) => key.toLowerCase())];
191174
+ const selectMatches = candidates.filter((candidate) => {
191175
+ const tags2 = normalizeTagBag(candidate.tags);
191176
+ if (selectKeys.some((key) => slugEquals(tags2[key] ?? "", repoSlug))) return true;
191177
+ const org = tags2[FOX_ORG_TAG] ?? "";
191178
+ const repo = tags2[FOX_REPO_TAG] ?? "";
191179
+ return org.length > 0 && repo.length > 0 && slugEquals(`${org}/${repo}`, repoSlug);
191180
+ });
191181
+ const uniqueExact = [...new Set(selectMatches.map((candidate) => candidate.id))];
191088
191182
  if (uniqueExact.length === 1) {
191089
191183
  return {
191090
191184
  ids: uniqueExact,
191091
191185
  selectId: uniqueExact[0],
191092
- evidence: [`Exactly one API tagged ${CANONICAL_REPO_TAG}=${ctx.repoSlug}`]
191186
+ evidence: [`Exactly one API carries a select-grade repo tag matching ${repoSlug}`]
191093
191187
  };
191094
191188
  }
191095
191189
  if (uniqueExact.length > 1) {
191096
191190
  return {
191097
191191
  ids: uniqueExact,
191098
- evidence: [`Found ${uniqueExact.length} APIs tagged ${CANONICAL_REPO_TAG}=${ctx.repoSlug}`]
191192
+ evidence: [`Found ${uniqueExact.length} APIs with select-grade repo tags matching ${repoSlug}`]
191099
191193
  };
191100
191194
  }
191101
- const repoName = ctx.repoSlug.split("/").pop()?.trim();
191195
+ const graphHit = await tierTagGraphFallback(candidates, ctx);
191196
+ if (graphHit) return graphHit;
191197
+ const repoName = repoSlug.split("/").pop()?.trim();
191102
191198
  const genericMatches = candidates.filter((candidate) => {
191103
- const tags2 = candidate.tags ?? {};
191199
+ const tags2 = normalizeTagBag(candidate.tags);
191104
191200
  return GENERIC_TAG_KEYS.some((key) => {
191105
191201
  const value = tags2[key] ?? "";
191106
- return value === ctx.repoSlug || repoName !== void 0 && value === repoName;
191202
+ return slugEquals(value, repoSlug) || repoName !== void 0 && slugEquals(value, repoName);
191107
191203
  });
191108
191204
  });
191109
191205
  if (genericMatches.length > 0) {
@@ -191114,6 +191210,41 @@ async function tierTagPreFilter(candidates, ctx) {
191114
191210
  }
191115
191211
  return void 0;
191116
191212
  }
191213
+ async function tierTagGraphFallback(candidates, ctx) {
191214
+ if (!ctx.resourceGraphClient || !ctx.subscriptionId || !ctx.repoSlug) return void 0;
191215
+ let rows;
191216
+ try {
191217
+ rows = await ctx.resourceGraphClient.queryResources(
191218
+ ctx.subscriptionId,
191219
+ buildRepoTagLookupQuery(ctx.repoSlug, ctx.repoTagKeys ?? [], ctx.resourceGroup)
191220
+ );
191221
+ } catch {
191222
+ return void 0;
191223
+ }
191224
+ if (rows.length === 0) return void 0;
191225
+ const byLowerId = new Map(candidates.map((candidate) => [candidate.id.toLowerCase(), candidate.id]));
191226
+ const matched = [];
191227
+ const seen = /* @__PURE__ */ new Set();
191228
+ for (const row of rows) {
191229
+ const canonical = byLowerId.get(row.id.toLowerCase());
191230
+ if (canonical !== void 0 && !seen.has(canonical)) {
191231
+ seen.add(canonical);
191232
+ matched.push(canonical);
191233
+ }
191234
+ }
191235
+ if (matched.length === 0) return void 0;
191236
+ if (matched.length === 1) {
191237
+ return {
191238
+ ids: matched,
191239
+ selectId: matched[0],
191240
+ evidence: [`Resource Graph tag lookup matched exactly one enumerated candidate for ${ctx.repoSlug}`]
191241
+ };
191242
+ }
191243
+ return {
191244
+ ids: matched,
191245
+ evidence: [`Resource Graph tag lookup matched ${matched.length} enumerated candidate(s) for ${ctx.repoSlug}`]
191246
+ };
191247
+ }
191117
191248
  function tierNamingHeuristic(candidates, ctx) {
191118
191249
  const slugs = slugifyRepoName(ctx.repoSlug);
191119
191250
  if (slugs.length === 0) return void 0;
@@ -191163,39 +191294,6 @@ async function runNarrowingPipeline(ctx, allCandidates) {
191163
191294
  return void 0;
191164
191295
  }
191165
191296
 
191166
- // src/lib/resolve/resource-graph-query.ts
191167
- function escapeKqlString(value) {
191168
- return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
191169
- }
191170
- var CANDIDATE_TYPES = [
191171
- "microsoft.apimanagement/service/apis",
191172
- "microsoft.apimanagement/service/workspaces/apis",
191173
- "microsoft.web/sites",
191174
- "microsoft.web/customapis",
191175
- "microsoft.logic/workflows",
191176
- "microsoft.resources/templatespecs/versions",
191177
- "microsoft.eventgrid/topics",
191178
- "microsoft.eventgrid/domains",
191179
- "microsoft.eventgrid/systemtopics",
191180
- "microsoft.servicebus/namespaces/topics"
191181
- ];
191182
- function buildCandidateQuery(resourceGroup) {
191183
- const typeFilter = CANDIDATE_TYPES.map((type) => `'${type}'`).join(", ");
191184
- const lines = [
191185
- "Resources",
191186
- `| where type in~ (${typeFilter})`
191187
- ];
191188
- const trimmedGroup = (resourceGroup ?? "").trim();
191189
- if (trimmedGroup) {
191190
- lines.push(`| where resourceGroup =~ '${escapeKqlString(trimmedGroup)}'`);
191191
- }
191192
- lines.push(
191193
- "| extend apiType=tostring(properties.apiType), isCurrent=tobool(properties.isCurrent), apiRevision=tostring(properties.apiRevision), apiVersionSetId=tostring(properties.apiVersionSetId), apiDefinitionUrl=tostring(properties.siteConfig.apiDefinition.url)",
191194
- "| project id, name, type, resourceGroup, tags, apiType, isCurrent, apiRevision, apiVersionSetId, apiDefinitionUrl"
191195
- );
191196
- return lines.join("\n");
191197
- }
191198
-
191199
191297
  // src/lib/estate/enumerate.ts
191200
191298
  var SLUG_TAG_KEYS = ["postman:repo", "github:repository", "repo", "repository"];
191201
191299
  var ORG_TAG_KEY = "githuborg";
@@ -192608,6 +192706,7 @@ function resolveInputs(env = process.env) {
192608
192706
  const expectedApiIds = [apiId2, ...parseStringArrayJson(expectedApiIdsRaw, "expected-api-ids-json")].filter(
192609
192707
  (value) => Boolean(value)
192610
192708
  );
192709
+ const repoTagKeysRaw = getInput2("repo-tag-keys-json", env) ?? "[]";
192611
192710
  return {
192612
192711
  mode,
192613
192712
  subscriptionId: subscriptionId3,
@@ -192619,6 +192718,7 @@ function resolveInputs(env = process.env) {
192619
192718
  expectedApiIds: [...new Set(expectedApiIds)],
192620
192719
  apiFilter,
192621
192720
  serviceMapping: parseServiceMapping(serviceMappingRaw),
192721
+ repoTagKeys: [...new Set(parseStringArrayJson(repoTagKeysRaw, "repo-tag-keys-json").map((key) => key.toLowerCase()))],
192622
192722
  outputDir,
192623
192723
  maxCandidates: parseBoundedInteger(getInput2("max-candidates", env), "max-candidates", 50, 1, 1e4),
192624
192724
  dryRun: parseBoolean(getInput2("dry-run", env), "dry-run", false),
@@ -193034,8 +193134,11 @@ async function runResolveOne(inputs, dependencies) {
193034
193134
  {
193035
193135
  repoSlug: inputs.repoContext.repoSlug,
193036
193136
  subscriptionId: subscriptionId3,
193137
+ resourceGroup: inputs.resourceGroup,
193138
+ repoTagKeys: inputs.repoTagKeys,
193037
193139
  serviceHints: signals.serviceHints,
193038
- signals
193140
+ signals,
193141
+ resourceGraphClient: dependencies.createResourceGraphClient?.()
193039
193142
  },
193040
193143
  enumerated.map((candidate) => ({
193041
193144
  id: candidate.id,
@@ -193147,8 +193250,11 @@ async function runDiscoverMany(inputs, dependencies) {
193147
193250
  {
193148
193251
  repoSlug: inputs.repoContext.repoSlug,
193149
193252
  subscriptionId: subscriptionId3,
193253
+ resourceGroup: inputs.resourceGroup,
193254
+ repoTagKeys: inputs.repoTagKeys,
193150
193255
  serviceHints: signals.serviceHints,
193151
- signals
193256
+ signals,
193257
+ resourceGraphClient: dependencies.createResourceGraphClient?.()
193152
193258
  },
193153
193259
  enumerated.map((candidate) => ({
193154
193260
  id: candidate.id,
package/docs/providers.md CHANGED
@@ -71,3 +71,5 @@ Not a provider. `mode: discover-estate` runs a separate association-only enumera
71
71
  ## Ordering and narrowing
72
72
 
73
73
  Probe order is `apim`, `app-service`, `custom-apis`, `logic-apps`, `template-specs`, `event-grid`, `service-bus`, `function-bindings`, `iac-local`. Candidates from all available providers enter the same four-tier narrowing pipeline (`iac-fingerprint`, `rg-correlation`, `tag-prefilter`, `naming-heuristic`); the chosen tier is reported in the `narrowing-strategy` output. Tags in the `postman:*` namespace (`postman:repo`, `postman:project-name`) are the strongest ownership signals.
74
+
75
+ The `tag-prefilter` tier treats three tag shapes as select-grade (exactly one match selects at confidence 100): canonical `postman:repo=<org>/<repo>`, the Fox-style `GithubOrg`/`GithubRepo` pair composed as `<org>/<repo>`, and any extra keys registered through the CLI-only `repo-tag-keys-json` input. Tag keys compare case-insensitively (Azure tag names are case-insensitive) and values ignore case plus a trailing `.git`. When no enumerated candidate carries a matching tag bag and a Resource Graph client is available, the tier issues one targeted tag-lookup query (`buildRepoTagLookupQuery`) and intersects the returned ARM IDs with the enumerated candidate set — exactly one intersecting hit is select-grade; query failures narrow nothing (fail-soft). Generic tags (`repo`, `repository`, `service`, `github:repository`) stay narrow-only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postman-cse/onboarding-azure-spec-discovery",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Discover Azure-hosted API specs and hand the result to Postman API onboarding.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",