From fda5e7c149dc8b09da8d632aa7e718c4f5577136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:47:03 +0000 Subject: [PATCH 1/5] Initial plan From e22f2ae3318ee45bde2591b184452450eebfd5f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:12:34 +0000 Subject: [PATCH 2/5] feat: add allowed-teams support to mentions configuration Allows organizations to specify team slugs in safe-outputs.mentions.allowed-teams so that all members of those teams are automatically allowed to be mentioned without listing individual usernames. - Add AllowedTeams []string field to MentionsConfig struct - Parse allowed-teams in parseMentionsConfig (normalizes @ prefix) - Include allowedTeams in buildMentionsHandlerConfig - Add fetchTeamMembers() helper in resolve_mentions_from_payload.cjs that fetches team members via GitHub API, supporting both "team-slug" and "org/team-slug" formats, excluding bots - Wire allowedTeams handling into resolveAllowedMentionsFromPayload - Add Go and JS tests for all new functionality - Update frontmatter-full.md and safe-outputs.md documentation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/resolve_mentions_from_payload.cjs | 53 +++++ .../js/resolve_mentions_from_payload.test.cjs | 208 +++++++++++++++++- .../docs/reference/frontmatter-full.md | 8 + .../content/docs/reference/safe-outputs.md | 26 +++ pkg/workflow/compiler_types.go | 4 + pkg/workflow/safe_outputs_config.go | 3 + pkg/workflow/safe_outputs_mentions_test.go | 120 ++++++++++ pkg/workflow/safe_outputs_messages_config.go | 19 ++ 8 files changed, 440 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/resolve_mentions_from_payload.cjs b/actions/setup/js/resolve_mentions_from_payload.cjs index 4195a93361a..6068e27a6a7 100644 --- a/actions/setup/js/resolve_mentions_from_payload.cjs +++ b/actions/setup/js/resolve_mentions_from_payload.cjs @@ -101,6 +101,46 @@ 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. + * @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} Array of member logins (non-bot) + */ +async function fetchTeamMembers(teamEntry, defaultOrg, github, core) { + let org = defaultOrg; + let teamSlug = teamEntry; + + // Support "org/team-slug" format + const slashIdx = teamEntry.indexOf("/"); + if (slashIdx !== -1) { + org = teamEntry.slice(0, slashIdx); + teamSlug = teamEntry.slice(slashIdx + 1); + } + + if (!org || !teamSlug) { + core.warning(`[MENTIONS] Skipping invalid team entry: "${teamEntry}"`); + return []; + } + + try { + const response = await github.rest.teams.listMembersInOrg({ + org, + team_slug: teamSlug, + per_page: 100, + }); + const logins = response.data.filter(member => member.type !== "Bot" && typeof member.login === "string").map(member => member.login); + core.info(`[MENTIONS] Fetched ${logins.length} member(s) from team ${org}/${teamSlug}`); + return logins; + } catch (error) { + 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 @@ -126,6 +166,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 { @@ -137,6 +178,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) { + 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(", ")}`); @@ -192,6 +244,7 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions module.exports = { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, + fetchTeamMembers, pushNonBotUser, pushNonBotAssignees, }; diff --git a/actions/setup/js/resolve_mentions_from_payload.test.cjs b/actions/setup/js/resolve_mentions_from_payload.test.cjs index c0447abd432..1082797cf13 100644 --- a/actions/setup/js/resolve_mentions_from_payload.test.cjs +++ b/actions/setup/js/resolve_mentions_from_payload.test.cjs @@ -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, warning: ReturnType, error: ReturnType }} */ function makeMockCore() { @@ -383,4 +383,210 @@ 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, + }); + }); + + 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, + }); + }); + + 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, + }); + }); + + 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, + }); + }); + + 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("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(); + }); }); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 1e06f3c0b2e..2fa6b06c3f6 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -8453,6 +8453,14 @@ safe-outputs: allowed: [] # Array of strings + # List of team slugs whose members are always allowed to be mentioned. Accepts + # "team-slug" (resolved against the current org) or "org/team-slug" format. + # Members of these teams are fetched from the GitHub API at runtime and added to + # the allowed mentions list. Bots are excluded. + # (optional) + allowed-teams: [] + # Array of strings, e.g. ["myorg/eng", "reviewers"] + # Maximum number of mentions allowed per message. Default: 50 Supports integer or # GitHub Actions expression (e.g. '${{ inputs.max }}'). # (optional) diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 7ab512d33a2..64a3bb12e70 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1746,6 +1746,32 @@ safe-outputs: Accepts a literal integer or a GitHub Actions expression string (e.g., `${{ inputs.max-mentions }}`). Set to `0` to escape all bot trigger phrases. Default: 10. +### Mention Filtering (`mentions:`) + +By default, `@mentions` in AI-generated content are escaped with backticks unless the mentioned user is a verified collaborator or inferred from the event context (issue/PR author, assignees, etc.). Use `mentions:` to control this behavior: + +```yaml wrap +safe-outputs: + mentions: false # Escape all mentions + add-comment: {} +``` + +```yaml wrap +safe-outputs: + mentions: + allow-team-members: true # Allow repo collaborators (default: true) + allow-context: true # Allow event context participants (default: true) + allowed: # Individual users/bots always allowed + - trusted-bot + allowed-teams: # Team members always allowed + - myorg/eng # org/team-slug format + - reviewers # team-slug only (uses current org) + max: 50 # Max mentions per message (default: 50) + add-comment: {} +``` + +**`allowed-teams`** lets organizations allow all members of specific GitHub teams to be mentioned without listing individual usernames. Team members are fetched from the GitHub API at runtime using `GET /orgs/{org}/teams/{team_slug}/members`. Bot accounts within the team are excluded. Use `org/team-slug` for cross-org teams or just `team-slug` to resolve against the current repository's organization. + ### Templatable Fields `max`, `expires`, and `max-bot-mentions` accept GitHub Actions expression strings in addition to literal integers, allowing workflow inputs or repository variables to control limits at runtime: diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 28b83fad1d3..6fba19035ee 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -786,6 +786,10 @@ type MentionsConfig struct { // Allowed is a list of user/bot names always allowed (bots not allowed by default) Allowed []string `yaml:"allowed,omitempty" json:"allowed,omitempty"` + // AllowedTeams is a list of team slugs whose members are always allowed to be mentioned. + // Accepts "team-slug" (resolved against the current org) or "org/team-slug" format. + AllowedTeams []string `yaml:"allowed-teams,omitempty" json:"allowedTeams,omitempty"` + // Max is the maximum number of mentions per message (default: 50) Max *int `yaml:"max,omitempty" json:"max,omitempty"` } diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index a64e2170a3f..bf2e9b2b37f 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -1009,6 +1009,9 @@ func buildMentionsHandlerConfig(m *MentionsConfig) map[string]any { if len(m.Allowed) > 0 { cfg["allowed"] = m.Allowed } + if len(m.AllowedTeams) > 0 { + cfg["allowedTeams"] = m.AllowedTeams + } if m.Max != nil { cfg["max"] = *m.Max } diff --git a/pkg/workflow/safe_outputs_mentions_test.go b/pkg/workflow/safe_outputs_mentions_test.go index 79e54fa6273..c290280fd65 100644 --- a/pkg/workflow/safe_outputs_mentions_test.go +++ b/pkg/workflow/safe_outputs_mentions_test.go @@ -100,6 +100,50 @@ func TestParseMentionsConfig_Object(t *testing.T) { Allowed: []string{"pelikhan", "bot1", "user2"}, }, }, + { + name: "allowed-teams with org/team format", + input: map[string]any{ + "allowed-teams": []any{"myorg/my-team", "anotherorg/eng"}, + }, + expected: &MentionsConfig{ + AllowedTeams: []string{"myorg/my-team", "anotherorg/eng"}, + }, + }, + { + name: "allowed-teams with team-slug only", + input: map[string]any{ + "allowed-teams": []any{"my-team"}, + }, + expected: &MentionsConfig{ + AllowedTeams: []string{"my-team"}, + }, + }, + { + name: "allowed-teams with @ prefix - should normalize", + input: map[string]any{ + "allowed-teams": []any{"@myorg/my-team"}, + }, + expected: &MentionsConfig{ + AllowedTeams: []string{"myorg/my-team"}, + }, + }, + { + name: "full config with allowed-teams", + input: map[string]any{ + "allow-team-members": true, + "allow-context": false, + "allowed": []any{"bot1"}, + "allowed-teams": []any{"myorg/eng"}, + "max": 10, + }, + expected: &MentionsConfig{ + AllowTeamMembers: boolPtr(true), + AllowContext: boolPtr(false), + Allowed: []string{"bot1"}, + AllowedTeams: []string{"myorg/eng"}, + Max: new(10), + }, + }, { name: "max as float", input: map[string]any{ @@ -157,6 +201,19 @@ func TestParseMentionsConfig_Object(t *testing.T) { } } + // Check AllowedTeams + if len(tt.expected.AllowedTeams) > 0 { + if len(result.AllowedTeams) != len(tt.expected.AllowedTeams) { + t.Errorf("Expected AllowedTeams length %d, got %d", len(tt.expected.AllowedTeams), len(result.AllowedTeams)) + } else { + for i, expected := range tt.expected.AllowedTeams { + if result.AllowedTeams[i] != expected { + t.Errorf("Expected AllowedTeams[%d] to be %q, got %q", i, expected, result.AllowedTeams[i]) + } + } + } + } + // Check Max if tt.expected.Max != nil { if result.Max == nil { @@ -208,6 +265,30 @@ func TestGenerateSafeOutputsConfig_WithMentions(t *testing.T) { "max": 20, }, }, + { + name: "allowed-teams propagates to handler config", + config: &MentionsConfig{ + AllowedTeams: []string{"myorg/eng", "myorg/reviewers"}, + }, + expected: map[string]any{ + "allowedTeams": []string{"myorg/eng", "myorg/reviewers"}, + }, + }, + { + name: "full config with allowed-teams", + config: &MentionsConfig{ + AllowTeamMembers: boolPtr(false), + AllowedTeams: []string{"myorg/eng"}, + Allowed: []string{"bot1"}, + Max: new(30), + }, + expected: map[string]any{ + "allowTeamMembers": false, + "allowedTeams": []string{"myorg/eng"}, + "allowed": []string{"bot1"}, + "max": 30, + }, + }, } for _, tt := range tests { @@ -343,6 +424,32 @@ func TestExtractSafeOutputsConfig_WithMentions(t *testing.T) { Allowed: []string{"user1", "user2", "user3"}, }, }, + { + name: "mentions with allowed-teams", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "mentions": map[string]any{ + "allowed-teams": []any{"myorg/eng", "myorg/reviewers"}, + }, + }, + }, + expected: &MentionsConfig{ + AllowedTeams: []string{"myorg/eng", "myorg/reviewers"}, + }, + }, + { + name: "mentions with allowed-teams @ prefix - should normalize", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "mentions": map[string]any{ + "allowed-teams": []any{"@myorg/eng"}, + }, + }, + }, + expected: &MentionsConfig{ + AllowedTeams: []string{"myorg/eng"}, + }, + }, } for _, tt := range tests { @@ -398,6 +505,19 @@ func TestExtractSafeOutputsConfig_WithMentions(t *testing.T) { } } + // Check AllowedTeams + if len(tt.expected.AllowedTeams) > 0 { + if len(config.Mentions.AllowedTeams) != len(tt.expected.AllowedTeams) { + t.Errorf("Expected AllowedTeams length %d, got %d", len(tt.expected.AllowedTeams), len(config.Mentions.AllowedTeams)) + } else { + for i, expected := range tt.expected.AllowedTeams { + if config.Mentions.AllowedTeams[i] != expected { + t.Errorf("Expected AllowedTeams[%d] to be %q, got %q", i, expected, config.Mentions.AllowedTeams[i]) + } + } + } + } + // Check Max if tt.expected.Max != nil { if config.Mentions.Max == nil { diff --git a/pkg/workflow/safe_outputs_messages_config.go b/pkg/workflow/safe_outputs_messages_config.go index c48abe36cab..e4ba1a98cee 100644 --- a/pkg/workflow/safe_outputs_messages_config.go +++ b/pkg/workflow/safe_outputs_messages_config.go @@ -96,6 +96,25 @@ func parseMentionsConfig(mentions any) *MentionsConfig { } } + // Parse allowed-teams list + if allowedTeams, exists := mentionsMap["allowed-teams"]; exists { + if allowedTeamsArray, ok := allowedTeams.([]any); ok { + var allowedTeamsStrings []string + for _, item := range allowedTeamsArray { + if str, ok := item.(string); ok { + // Normalize team slug by removing '@' prefix if present + normalized := str + if len(str) > 0 && str[0] == '@' { + normalized = str[1:] + safeOutputMessagesLog.Printf("Normalized team mention '%s' to '%s'", str, normalized) + } + allowedTeamsStrings = append(allowedTeamsStrings, normalized) + } + } + config.AllowedTeams = allowedTeamsStrings + } + } + // Parse max if maxVal, exists := mentionsMap["max"]; exists { switch v := maxVal.(type) { From b807ff3b61c463e249907ca60d8ac0e026bb7b5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:48:41 +0000 Subject: [PATCH 3/5] fix(safe-outputs): improve fetchTeamMembers error handling and pagination Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/resolve_mentions_from_payload.cjs | 40 ++++++++-- .../js/resolve_mentions_from_payload.test.cjs | 75 +++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/resolve_mentions_from_payload.cjs b/actions/setup/js/resolve_mentions_from_payload.cjs index 6068e27a6a7..80379bf19b1 100644 --- a/actions/setup/js/resolve_mentions_from_payload.cjs +++ b/actions/setup/js/resolve_mentions_from_payload.cjs @@ -104,11 +104,12 @@ function extractKnownAuthorsFromPayload(context) { /** * 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} Array of member logins (non-bot) + * @returns {Promise} Array of member logins (non-bot), empty on any failure */ async function fetchTeamMembers(teamEntry, defaultOrg, github, core) { let org = defaultOrg; @@ -127,16 +128,39 @@ async function fetchTeamMembers(teamEntry, defaultOrg, github, core) { } try { - const response = await github.rest.teams.listMembersInOrg({ - org, - team_slug: teamSlug, - per_page: 100, - }); - const logins = response.data.filter(member => member.type !== "Bot" && typeof member.login === "string").map(member => member.login); + 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++; + } + core.info(`[MENTIONS] Fetched ${logins.length} member(s) from team ${org}/${teamSlug}`); return logins; } catch (error) { - core.warning(`[MENTIONS] Failed to fetch members for team ${org}/${teamSlug}: ${getErrorMessage(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 []; } } diff --git a/actions/setup/js/resolve_mentions_from_payload.test.cjs b/actions/setup/js/resolve_mentions_from_payload.test.cjs index 1082797cf13..518475cde82 100644 --- a/actions/setup/js/resolve_mentions_from_payload.test.cjs +++ b/actions/setup/js/resolve_mentions_from_payload.test.cjs @@ -414,6 +414,7 @@ describe("resolveAllowedMentionsFromPayload", () => { org: "myorg", team_slug: "eng", per_page: 100, + page: 1, }); }); @@ -443,6 +444,7 @@ describe("resolveAllowedMentionsFromPayload", () => { org: "contextorg", team_slug: "eng-team", per_page: 100, + page: 1, }); }); @@ -527,6 +529,7 @@ describe("fetchTeamMembers", () => { org: "myorg", team_slug: "eng", per_page: 100, + page: 1, }); }); @@ -546,9 +549,30 @@ describe("fetchTeamMembers", () => { 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: { @@ -582,6 +606,57 @@ describe("fetchTeamMembers", () => { 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); From fbc776fb5efe6ac3c1e84bd98d715ead575cf48c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:10:28 +0000 Subject: [PATCH 4/5] docs(safe-outputs): clarify allowed-teams requires read:org permission Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/frontmatter-full.md | 4 ++++ docs/src/content/docs/reference/safe-outputs.md | 8 ++++++++ pkg/parser/schemas/main_workflow_schema.json | 9 +++++++++ pkg/workflow/compiler_types.go | 4 ++++ 4 files changed, 25 insertions(+) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 2fa6b06c3f6..6100c62629e 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -8457,6 +8457,10 @@ safe-outputs: # "team-slug" (resolved against the current org) or "org/team-slug" format. # Members of these teams are fetched from the GitHub API at runtime and added to # the allowed mentions list. Bots are excluded. + # IMPORTANT: Requires read:org scope — not available with the default GITHUB_TOKEN. + # Use a classic PAT with read:org, a fine-grained PAT with Members:Read, or a + # GitHub App with the Members:Read permission. Without the required scope, team + # lookups fail silently (warning logged) and those team members are skipped. # (optional) allowed-teams: [] # Array of strings, e.g. ["myorg/eng", "reviewers"] diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 64a3bb12e70..1c7203118c1 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1772,6 +1772,14 @@ safe-outputs: **`allowed-teams`** lets organizations allow all members of specific GitHub teams to be mentioned without listing individual usernames. Team members are fetched from the GitHub API at runtime using `GET /orgs/{org}/teams/{team_slug}/members`. Bot accounts within the team are excluded. Use `org/team-slug` for cross-org teams or just `team-slug` to resolve against the current repository's organization. +> [!IMPORTANT] +> `allowed-teams` requires the workflow token to have `read:org` scope. The default `GITHUB_TOKEN` does **not** include this scope. Use one of the following: +> - A **classic PAT** with the `read:org` scope stored as a repository secret +> - A **fine-grained PAT** with the "Members" repository permission (read) +> - A **GitHub App** installation token with the "Members" permission (read) +> +> If the token lacks `read:org`, team membership lookup will fail with HTTP 403/404 and a warning will be logged. The workflow continues without those team members in the allowlist. + ### Templatable Fields `max`, `expires`, and `max-bot-mentions` accept GitHub Actions expression strings in addition to literal integers, allowing workflow inputs or repository variables to control limits at runtime: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e74b5748256..18044ce5ed0 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -10221,6 +10221,15 @@ "minLength": 1 } }, + "allowed-teams": { + "type": "array", + "description": "List of team slugs whose members are always allowed to be mentioned. Accepts 'team-slug' (resolved against the current org) or 'org/team-slug' format. Team members are fetched from the GitHub API at runtime; bots are excluded. IMPORTANT: requires read:org scope — not available with the default GITHUB_TOKEN. Use a classic PAT with read:org, a fine-grained PAT with Members:Read, or a GitHub App with the Members:Read permission. Without the required scope, team lookups fail with a warning and those members are skipped.", + "items": { + "type": "string", + "minLength": 1 + }, + "examples": [["myorg/eng"], ["reviewers"], ["myorg/eng", "myorg/docs"]] + }, "max": { "description": "Maximum number of mentions allowed per message. Default: 50 Supports integer or GitHub Actions expression (e.g. '${{ inputs.max }}').", "oneOf": [ diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 6fba19035ee..d8659f6809f 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -788,6 +788,10 @@ type MentionsConfig struct { // AllowedTeams is a list of team slugs whose members are always allowed to be mentioned. // Accepts "team-slug" (resolved against the current org) or "org/team-slug" format. + // Requires the workflow token to have read:org scope (a fine-grained PAT, classic PAT with + // read:org, or a GitHub App with the Members:Read permission). The default GITHUB_TOKEN + // does not include read:org and will produce a 403/404 warning; team members will be skipped + // but the workflow will not fail. AllowedTeams []string `yaml:"allowed-teams,omitempty" json:"allowedTeams,omitempty"` // Max is the maximum number of mentions per message (default: 50) From 64ad5fa0af833ba01fa799361084319a0feabaed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:20:29 +0000 Subject: [PATCH 5/5] docs(adr): add draft ADR-40368 for runtime team-membership mention resolution --- ...68-resolve-mentions-via-team-membership.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/adr/40368-resolve-mentions-via-team-membership.md diff --git a/docs/adr/40368-resolve-mentions-via-team-membership.md b/docs/adr/40368-resolve-mentions-via-team-membership.md new file mode 100644 index 00000000000..29003a2169f --- /dev/null +++ b/docs/adr/40368-resolve-mentions-via-team-membership.md @@ -0,0 +1,41 @@ +# ADR-40368: Resolve Allowed Mentions via GitHub Team Membership at Runtime + +**Date**: 2026-06-19 +**Status**: Draft + +## Context + +The `mentions.allowed` safe-output configuration requires enumerating every individual user or bot permitted to be mentioned in AI-generated content. For organizations that want their engineers to be freely mentionable, this list becomes long, must be kept in sync with personnel changes, and is impractical to maintain by hand. GitHub already models these groups as teams, so the membership data needed to drive an allowlist exists but was not being consumed. The mention-resolution pipeline runs at workflow runtime inside `resolve_mentions_from_payload.cjs`, where the GitHub API client is available. + +## Decision + +We will add an `allowed-teams` option to `MentionsConfig` whose members are resolved **dynamically at runtime** rather than enumerated statically. The compiler (`compiler_types.go`, `safe_outputs_messages_config.go`, `safe_outputs_config.go`) parses and normalizes the `allowed-teams` list (stripping a leading `@`, accepting both `org/team-slug` and bare `team-slug` forms) and emits it as `allowedTeams` to the JS runtime. At runtime, `fetchTeamMembers` calls `github.rest.teams.listMembersInOrg` with full pagination (capped at 1,000 members), excludes bot accounts, and adds the resulting logins to the allowed-mention set. All failure paths (rate limit, missing `read:org` scope, missing team) are non-fatal: a warning is logged and the team is skipped so the workflow continues. + +## Alternatives Considered + +### Alternative 1: Keep enumerating individual users in `allowed` +The existing mechanism already supports listing usernames explicitly. It requires no new code and no additional token scope. It was rejected because it does not scale: large teams must be transcribed by hand and re-edited whenever membership changes, which is exactly the friction this change targets. + +### Alternative 2: Reuse the existing `allow-team-members` collaborator resolution +The pipeline already resolves repository collaborators via `allow-team-members`. We could have relied on that alone. It was rejected because collaborator status is repo-scoped and does not let a workflow allow a *specific named team* independently of repository access; `allowed-teams` is applied independently of `allow-team-members`. + +### Alternative 3: Expand team membership statically at compile time +The compiler could resolve team members once and bake the list into the generated workflow. This avoids per-run API calls and the `read:org` requirement at runtime. It was rejected because membership would go stale between compilations and the compiler does not have a reliable authenticated org-scoped token, whereas runtime resolution always reflects current membership. + +## Consequences + +### Positive +- Organizations declare a team once instead of maintaining a per-user allowlist; membership stays current automatically. +- Membership is resolved against live GitHub data at runtime, so personnel changes need no workflow edits. + +### Negative +- Requires the workflow token to carry `read:org` scope, which the default `GITHUB_TOKEN` does not provide; users must supply a classic PAT, fine-grained PAT, or GitHub App token. +- Adds runtime GitHub API calls (paginated, up to 10 requests per team), introducing latency and rate-limit exposure that the static approach avoided. + +### Neutral +- Bot accounts within a team are deliberately excluded, matching the existing bot-handling policy for individual mentions. +- The 1,000-member pagination cap silently bounds very large teams; members beyond the cap are not included. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27848743385) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*