Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions actions/setup/js/resolve_mentions_from_payload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,70 @@ function extractKnownAuthorsFromPayload(context) {
return users;
}

/**
* Fetch members of a GitHub team and return their logins.
* Accepts "team-slug" (resolved against the current org) or "org/team-slug" format.
* Failures are non-fatal: a warning is logged and an empty array is returned.
* @param {string} teamEntry - Team identifier, e.g. "my-team" or "myorg/my-team"
* @param {string} defaultOrg - The org to use when no org is specified in teamEntry
* @param {any} github - GitHub API client
* @param {any} core - GitHub Actions core
* @returns {Promise<string[]>} Array of member logins (non-bot), empty on any failure
*/
async function fetchTeamMembers(teamEntry, defaultOrg, github, core) {
let org = defaultOrg;
let teamSlug = teamEntry;

// Support "org/team-slug" format
const slashIdx = teamEntry.indexOf("/");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The Go parser strips leading @ from allowed-teams entries before emitting them into the JS config, so in practice this function never receives "@org/team". However, fetchTeamMembers is exported and its contract as a standalone function is unclear — a caller passing "@myorg/eng" directly would silently query for org "@myorg", which would 404.

💡 Add defensive normalization (1 line)
async function fetchTeamMembers(teamEntry, defaultOrg, github, core) {
  // Normalize: strip leading '@' to match the Go-layer convention
  teamEntry = teamEntry.startsWith("@") ? teamEntry.slice(1) : teamEntry;

  let org = defaultOrg;
  // ...

This makes the JS function self-consistent regardless of call site, mirrors what parseMentionsConfig already does on the Go side, and prevents a subtle silent failure in future tests or integrations that call fetchTeamMembers directly.

if (slashIdx !== -1) {
org = teamEntry.slice(0, slashIdx);
teamSlug = teamEntry.slice(slashIdx + 1);
}
Comment on lines +114 to +123

if (!org || !teamSlug) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchTeamMembers doesn't normalize @-prefixed entries, unlike the Go parser: the Go layer strips @ before emitting config, but since fetchTeamMembers is now exported and independently tested, a caller passing "@myorg/eng" directly would send "@myorg" as the org name, get a 404, and see a confusing "Failed to fetch members for team @myorg/eng" message instead of an "invalid team entry" validation warning.

💡 Suggested fix

Add defensive normalization at the top of the function, mirroring what Go already does:

async function fetchTeamMembers(teamEntry, defaultOrg, github, core) {
  // Normalize: strip leading '@' if present (Go parser does this too, but be defensive)
  const entry = teamEntry.startsWith("@") ? teamEntry.slice(1) : teamEntry;
  let org = defaultOrg;
  let teamSlug = entry;

  const slashIdx = entry.indexOf("/");
  // ... rest unchanged

This keeps the exported JS function self-contained and correctly validates @-prefixed inputs regardless of how the function is invoked.

core.warning(`[MENTIONS] Skipping invalid team entry: "${teamEntry}"`);
return [];
}

try {
const logins = /** @type {string[]} */ [];
let page = 1;
const maxPages = 10; // cap at 1000 members to avoid excessive API calls

while (page <= maxPages) {
const response = await github.rest.teams.listMembersInOrg({
org,
team_slug: teamSlug,
per_page: 100,
page,
});
const pageLogins = response.data.filter(member => member.type !== "Bot" && typeof member.login === "string").map(member => member.login);
logins.push(...pageLogins);
if (response.data.length < 100) {
break; // no more pages
}
page++;
}
Comment on lines +131 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent truncation at 1000 members emits no warning: when a team has >1000 members the while loop exits without logging anything, and the success message "Fetched 1000 member(s)" actively misleads operators into thinking the list is complete.

💡 Suggested fix

Add a warning after the loop closes so operators know the cap was hit:

while (page <= maxPages) {
  // ... existing pagination logic ...
  page++;
}

// Add this:
if (page > maxPages) {
  core.warning(
    `[MENTIONS] Team ${org}/${teamSlug} has more than ${maxPages * 100} members; ` +
    `only the first ${logins.length} were loaded. Consider splitting the team or raising the cap.`
  );
}

This matters for allowlist correctness: team members 1001+ are silently excluded and their @mentions will be unexpectedly escaped in AI-generated content, with no indication of why.


core.info(`[MENTIONS] Fetched ${logins.length} member(s) from team ${org}/${teamSlug}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] When a team has more than 1,000 members the while-loop exits silently after page 10 — core.info reports the (truncated) count with no indication that results are incomplete. Users with large teams will see some members unexpectedly absent from the allowlist with no warning to diagnose why.

💡 Log a warning on truncation + add a test
    }

    if (page > maxPages) {
      core.warning(
        `[MENTIONS] Team ${org}/${teamSlug} has more than 1,000 members; only the first 1,000 were fetched. Increase maxPages or split into sub-teams.`
      );
    }
    core.info(`[MENTIONS] Fetched ${logins.length} member(s) from team ${org}/${teamSlug}`);
    return logins;

Suggested test (in fetchTeamMembers describe):

it("warns when team exceeds 1000-member cap", async () => {
  const fullPage = Array.from({ length: 100 }, (_, i) => ({ login: `user${i}`, type: "User" }));
  const mockGithub = { rest: { teams: { listMembersInOrg: vi.fn(async () => ({ data: fullPage })) } } };
  await fetchTeamMembers("myorg/huge-team", "defaultorg", mockGithub, mockCore);
  expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("1,000"));
  expect(mockGithub.rest.teams.listMembersInOrg).toHaveBeenCalledTimes(10);
});

return logins;
} catch (error) {
const status = /** @type {any} */ error?.status;
const isRateLimit = status === 429 || (status === 403 && /rate.?limit/i.test(getErrorMessage(error)));
const isPermission = !isRateLimit && (status === 403 || status === 404);

if (isRateLimit) {
core.warning(`[MENTIONS] Rate limit reached while fetching team ${org}/${teamSlug} members - skipping team (retry later or reduce team count)`);
} else if (isPermission) {
core.warning(`[MENTIONS] Cannot access team ${org}/${teamSlug} (HTTP ${status}) - ensure the token has 'read:org' scope and the team exists`);
} else {
core.warning(`[MENTIONS] Failed to fetch members for team ${org}/${teamSlug}: ${getErrorMessage(error)}`);
}
return [];
}
}

/**
* Resolve allowed mentions from the current GitHub event context
* @param {any} context - GitHub Actions context
Expand All @@ -126,6 +190,7 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
const allowTeamMembers = mentionsConfig?.allowTeamMembers !== false; // default: true
const allowContext = mentionsConfig?.allowContext !== false; // default: true
const allowedList = mentionsConfig?.allowed || [];
const allowedTeams = mentionsConfig?.allowedTeams || [];
const maxMentions = mentionsConfig?.max || 50;

try {
Expand All @@ -137,6 +202,17 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
knownAuthors.push(...allowedList.filter(alias => typeof alias === "string" && alias.length > 0));
}

// Add members from allowed-teams (always included regardless of allow-team-members setting)
if (Array.isArray(allowedTeams) && allowedTeams.length > 0) {
core.info(`[MENTIONS] Fetching members for ${allowedTeams.length} configured team(s)`);
for (const teamEntry of allowedTeams) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequential team fetches block workflow execution for O(N × pages) API calls: all allowedTeams lookups are awaited one-by-one; with N teams each potentially making up to 10 paginated requests, large configs serialize tens of round-trips before mention resolution can proceed.

💡 Suggested fix

Parallelize at the team level with Promise.all:

if (Array.isArray(allowedTeams) && allowedTeams.length > 0) {
  core.info(`[MENTIONS] Fetching members for ${allowedTeams.length} configured team(s)`);
  const teamResults = await Promise.all(
    allowedTeams
      .filter(teamEntry => typeof teamEntry === "string" && teamEntry.length > 0)
      .map(teamEntry => fetchTeamMembers(teamEntry, owner, github, core))
  );
  for (const members of teamResults) {
    knownAuthors.push(...members);
  }
}

Each team is still capped at 10 pages internally; the change just runs teams concurrently rather than in series. If secondary rate limits are a concern, a small concurrency limiter (e.g. p-limit or a simple chunk-based approach) is preferable to full serialization.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Team members are fetched sequentially inside a for...of loop with await. For an org with several allowed-teams entries, latency grows linearly (one full paginated round-trip per team). Promise.all would fetch all teams in parallel.

💡 Suggested refactor
if (Array.isArray(allowedTeams) && allowedTeams.length > 0) {
  core.info(`[MENTIONS] Fetching members for ${allowedTeams.length} configured team(s)`);
  const teamMemberArrays = await Promise.all(
    allowedTeams
      .filter(e => typeof e === "string" && e.length > 0)
      .map(e => fetchTeamMembers(e, owner, github, core))
  );
  knownAuthors.push(...teamMemberArrays.flat());
}

Reduces latency from O(n teams) serial round-trips to a single parallel fan-out.

if (typeof teamEntry === "string" && teamEntry.length > 0) {
const teamMembers = await fetchTeamMembers(teamEntry, owner, github, core);
knownAuthors.push(...teamMembers);
}
}
}

// Add extra known authors (e.g. pre-fetched target issue authors for explicit item_number)
if (extraKnownAuthors && extraKnownAuthors.length > 0) {
core.info(`[MENTIONS] Adding ${extraKnownAuthors.length} extra known author(s): ${extraKnownAuthors.join(", ")}`);
Expand Down Expand Up @@ -192,6 +268,7 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions
module.exports = {
resolveAllowedMentionsFromPayload,
extractKnownAuthorsFromPayload,
fetchTeamMembers,
pushNonBotUser,
pushNonBotAssignees,
};
283 changes: 282 additions & 1 deletion actions/setup/js/resolve_mentions_from_payload.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vi.mock("./error_helpers.cjs", () => ({
getErrorMessage: vi.fn(err => (err instanceof Error ? err.message : String(err))),
}));

const { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, pushNonBotUser, pushNonBotAssignees } = await import("./resolve_mentions_from_payload.cjs");
const { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, fetchTeamMembers, pushNonBotUser, pushNonBotAssignees } = await import("./resolve_mentions_from_payload.cjs");

/** @returns {{ info: ReturnType<typeof vi.fn>, warning: ReturnType<typeof vi.fn>, error: ReturnType<typeof vi.fn> }} */
function makeMockCore() {
Expand Down Expand Up @@ -383,4 +383,285 @@ describe("resolveAllowedMentionsFromPayload", () => {
});
expect(result).not.toContain("copilot");
});

it("includes members from allowed-teams", async () => {
const context = {
eventName: "workflow_dispatch",
actor: "actor",
payload: {},
repo: { owner: "myorg", repo: "repo" },
};
const mockGithubWithTeams = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [
{ login: "alice", type: "User" },
{ login: "bob", type: "User" },
],
})),
},
},
};
const result = await resolveAllowedMentionsFromPayload(context, mockGithubWithTeams, mockCore, {
allowedTeams: ["myorg/eng"],
allowContext: false,
allowTeamMembers: false,
});
expect(result).toContain("alice");
expect(result).toContain("bob");
expect(mockGithubWithTeams.rest.teams.listMembersInOrg).toHaveBeenCalledWith({
org: "myorg",
team_slug: "eng",
per_page: 100,
page: 1,
});
});

it("allowed-teams with team-slug-only uses context owner", async () => {
const context = {
eventName: "workflow_dispatch",
actor: "actor",
payload: {},
repo: { owner: "contextorg", repo: "repo" },
};
const mockGithubWithTeams = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [{ login: "charlie", type: "User" }],
})),
},
},
};
const result = await resolveAllowedMentionsFromPayload(context, mockGithubWithTeams, mockCore, {
allowedTeams: ["eng-team"],
allowContext: false,
allowTeamMembers: false,
});
expect(result).toContain("charlie");
expect(mockGithubWithTeams.rest.teams.listMembersInOrg).toHaveBeenCalledWith({
org: "contextorg",
team_slug: "eng-team",
per_page: 100,
page: 1,
});
});

it("allowed-teams skips bots from team members", async () => {
const context = {
eventName: "workflow_dispatch",
actor: "actor",
payload: {},
repo: { owner: "myorg", repo: "repo" },
};
const mockGithubWithTeams = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [
{ login: "alice", type: "User" },
{ login: "bot-user", type: "Bot" },
],
})),
},
},
};
const result = await resolveAllowedMentionsFromPayload(context, mockGithubWithTeams, mockCore, {
allowedTeams: ["myorg/eng"],
allowContext: false,
allowTeamMembers: false,
});
expect(result).toContain("alice");
expect(result).not.toContain("bot-user");
});

it("allowed-teams gracefully handles API errors", async () => {
const context = {
eventName: "workflow_dispatch",
actor: "actor",
payload: {},
repo: { owner: "myorg", repo: "repo" },
};
const mockGithubWithTeams = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
throw new Error("API error");
}),
},
},
};
const result = await resolveAllowedMentionsFromPayload(context, mockGithubWithTeams, mockCore, {
allowedTeams: ["myorg/eng"],
allowContext: false,
allowTeamMembers: false,
});
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch members for team"));
});
});

describe("fetchTeamMembers", () => {
let mockCore;

beforeEach(() => {
mockCore = makeMockCore();
vi.clearAllMocks();
});

it("fetches team members with org/team-slug format", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [
{ login: "alice", type: "User" },
{ login: "bob", type: "User" },
],
})),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual(["alice", "bob"]);
expect(mockGithub.rest.teams.listMembersInOrg).toHaveBeenCalledWith({
org: "myorg",
team_slug: "eng",
per_page: 100,
page: 1,
});
});

it("uses default org when only team-slug is given", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [{ login: "charlie", type: "User" }],
})),
},
},
};
const result = await fetchTeamMembers("eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual(["charlie"]);
expect(mockGithub.rest.teams.listMembersInOrg).toHaveBeenCalledWith({
org: "defaultorg",
team_slug: "eng",
per_page: 100,
page: 1,
});
});

it("paginates through multiple pages to collect all members", async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({ login: `user${i}`, type: "User" }));
const page2 = [{ login: "last-user", type: "User" }];
let callCount = 0;
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
callCount++;
return { data: callCount === 1 ? page1 : page2 };
}),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toHaveLength(101);
expect(result).toContain("last-user");
expect(mockGithub.rest.teams.listMembersInOrg).toHaveBeenCalledTimes(2);
});

it("excludes bots from team members", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => ({
data: [
{ login: "alice", type: "User" },
{ login: "bot-user", type: "Bot" },
],
})),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual(["alice"]);
expect(result).not.toContain("bot-user");
});

it("returns empty array on API error and warns", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
throw new Error("Not Found");
}),
},
},
};
const result = await fetchTeamMembers("myorg/unknown-team", "defaultorg", mockGithub, mockCore);
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to fetch members for team myorg/unknown-team"));
});

it("warns with rate-limit message on HTTP 429", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
const err = new Error("Too Many Requests");
/** @type {any} */ err.status = 429;
throw err;
}),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Rate limit"));
});

it("warns with permission message on HTTP 403", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
const err = new Error("Forbidden");
/** @type {any} */ err.status = 403;
throw err;
}),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("read:org"));
});

it("warns with permission message on HTTP 404", async () => {
const mockGithub = {
rest: {
teams: {
listMembersInOrg: vi.fn(async () => {
const err = new Error("Not Found");
/** @type {any} */ err.status = 404;
throw err;
}),
},
},
};
const result = await fetchTeamMembers("myorg/eng", "defaultorg", mockGithub, mockCore);
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("read:org"));
});

it("returns empty array for invalid team entry and warns", async () => {
const mockGithub = { rest: { teams: { listMembersInOrg: vi.fn() } } };
const result = await fetchTeamMembers("", "defaultorg", mockGithub, mockCore);
expect(result).toEqual([]);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("invalid team entry"));
expect(mockGithub.rest.teams.listMembersInOrg).not.toHaveBeenCalled();
});
});
Loading
Loading