Skip to content

feat(owners): support GitHub groups in OWNERS - #486

Merged
mrbobbytables merged 3 commits into
mainfrom
feat/owners-github-groups
Oct 15, 2026
Merged

mrbobbytables merged 3 commits into
mainfrom
feat/owners-github-groups

Conversation

@mrbobbytables

Copy link
Copy Markdown
Member

Closes #485

OWNERS approvers/reviewers may now list "@org/team" entries. Members are resolved via the teams API (memoized per run) wherever OWNERS are evaluated (/approve, /lgtm, approve plugin, blunderbuss, root-OWNERS auth). Groups from other orgs or unreadable teams are ignored with a warning. Docs, tests and dist updated.

— hive: backend=copilot model=claude-sonnet-5.5


🐝 Hive Agent: contributor | SHA: 05f387b

List a team of the repository's org as @org/team; its members are
resolved through the teams API. Other orgs' groups are ignored.

Hive-Run: #485
Hive-Plan: owners-github-groups
Hive-Spec: owners#github-groups

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: mrbobbytables <mrbobbytables@users.noreply.github.com>
@mrbobbytables

Copy link
Copy Markdown
Member Author

/kind feature

@hivecommons-hive

Copy link
Copy Markdown
Contributor

Test coverage note (quality, hold-gated mode) — e2e only; nothing here blocks the PR.

npm run test:coverage:e2e on this head (05f387b) leaves the new expandGroups team arms unreached from dist/index.js — owners.ts 91.3% lines, uncovered 127-147 (cross-org warning, groups.add, the memoized teams.listMembersInOrg paginate, and the unreadable-team catch). Unit coverage is 100%; the gap is only the bundle. Tracked under #466 (folded).

Below is a bundle scenario that closes it, verified against this branch's committed dist/index.js: 3 tests pass, eslint clean, and owners.ts e2e goes to 97.97 Stmts / 100 Branch / 97.82 Lines (residual 380-382 = resetOwnersCaches, #483's ground). It also pins the per-run memo: two changed files under one OWNERS directory → exactly one teams read.

I can't push to your branch, so it's here to drop in as __tests__/bundle/ownersGroups.test.ts if you'd like it in this PR; otherwise I'll follow up with a quality/* PR once this merges.

__tests__/bundle/ownersGroups.test.ts
import type { FakeGithub } from './fakeGithub'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

import { prCommentEvent, pullBody } from '../utils/ownersData'
import { start } from './fakeGithub'
import { helpersFor, repo, token } from './helpers'
import { runBundle } from './runBundle'

vi.setConfig({ testTimeout: 30_000 })

// `@org/team` entries in OWNERS (src/utils/owners.ts expandGroups), driven through dist/index.js by `/lgtm` on a
// pull request whose base branch has OWNERS files: a same-org team is read once per run through the teams API and
// its members act as the listed logins; a group of another organization and a team the token cannot read each
// contribute nobody and leave a warning.
describe('dist/index.js /lgtm with @org/team entries in OWNERS', () => {
  const teamMembers = '/orgs/Codertocat/teams/maintainers/members'
  let gh: FakeGithub
  const { routeOwners } = helpersFor(() => gh)

  beforeAll(async () => {
    gh = await start()
  })
  beforeEach(() => gh.mergeQueueFallback({ pullRequestId: 'PR_none', headOid: pullBody.head.sha, enabled: false }))
  afterEach(() => gh.reset())
  afterAll(() => gh.close())

  function lgtm(commenter: string) {
    gh.route('POST', `${repo}/issues/1/comments`, { status: 201, body: {} })
    gh.route('POST', `${repo}/statuses/headsha`, { status: 201, body: {} })
    gh.route('GET', `${repo}/labels`, { status: 200, body: [{ name: 'lgtm' }] })
    gh.route('POST', `${repo}/issues/1/labels`, { status: 200, body: [] })
    return runBundle({
      eventName: 'issue_comment',
      payload: prCommentEvent('/lgtm', commenter),
      inputs: { ...token, 'prow-commands': '/lgtm' },
      apiUrl: gh.url,
    })
  }

  function teamReads() {
    return gh.requestsMatching('GET', /\/orgs\/Codertocat\/teams\//).map(r => r.path)
  }

  it('authorizes a member of a same-org team, reading the team once for every changed file', async () => {
    routeOwners({ 'sdk/OWNERS': 'approvers:\n- "@Codertocat/maintainers"\n' }, ['sdk/a.go', 'sdk/b.go'])
    gh.route('GET', teamMembers, { status: 200, body: [{ login: 'Bob' }, { login: 'carol' }] })

    const result = await lgtm('bob')

    expect(result.status, result.stdout).toBe(0)
    expect(result.errors).toEqual([])
    expect(result.stdout).not.toContain('::warning::OWNERS')
    expect(gh.requestsMatching('POST', /\/issues\/1\/labels$/)[0].body).toEqual({ labels: ['lgtm'] })
    // memoized per run: two covered files, one OWNERS directory, one teams read
    expect(teamReads()).toEqual([`${teamMembers}?per_page=100`])
    // the OWNERS tree answered, so neither the root OWNERS fallback nor the membership fallback runs
    expect(gh.requestsMatching('GET', /\/contents\/OWNERS$/)).toEqual([])
    expect(gh.requestsMatching('GET', /\/orgs\/Codertocat\/members\/|\/collaborators\//)).toEqual([])
  })

  it('ignores a group of another organization with a warning and never asks the teams API for it', async () => {
    routeOwners({ 'sdk/OWNERS': 'approvers:\n- "@other/maintainers"\n- alice\n' }, ['sdk/a.go'])

    const result = await lgtm('bob')

    expect(result.status, result.stdout).toBe(1)
    expect(result.stdout).toContain('::warning::OWNERS: ignoring group @other/maintainers: only groups of codertocat are supported')
    expect(result.errors.some(e => e.includes('bob is not a reviewer or approver for any changed file'))).toBe(true)
    expect(teamReads()).toEqual([])
    expect(gh.requestsMatching('POST', /\/issues\/1\/labels$/)).toEqual([])
  })

  it('treats a team it cannot read as empty, with a warning naming the team', async () => {
    routeOwners({ 'sdk/OWNERS': 'approvers:\n- "@Codertocat/maintainers"\n- alice\n' }, ['sdk/a.go'])
    gh.route('GET', teamMembers, { status: 404, body: { message: 'Not Found' } })

    const result = await lgtm('bob')

    expect(result.status, result.stdout).toBe(1)
    expect(result.stdout).toMatch(/::warning::OWNERS: could not read the members of @codertocat\/maintainers: .*Not Found/)
    expect(result.errors.some(e => e.includes('bob is not a reviewer or approver for any changed file'))).toBe(true)
    expect(teamReads()).toEqual([`${teamMembers}?per_page=100`])
    expect(gh.requestsMatching('POST', /\/issues\/1\/labels$/)).toEqual([])
  })
})

🐝 Hive Agent: quality | Instance: hosted-available-lke648397-260827-5q9t | SHA: 05f387b

— hive: agent=quality backend=copilot model=claude-fable-5.1 copilot=1.0.88

@mrbobbytables mrbobbytables left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review (posted as a comment since the author cannot request changes on their own PR). Design is sound and complete: every OWNERS consumer (approve, blunderbuss, /lgtm//approve auth, root-OWNERS trusted policy) goes through perFile or the two root helpers, all of which expand groups; OWNERS still come from the base-branch tip so a PR cannot grant itself a team; one teams-API call per team per run. Two things to fix, three suggestions — fixes follow in a commit on this branch.

Should fix

  1. Warning spam. owners.ts:131 core.warning(…ignoring group…) runs on every expandGroups call, and pullRequestOwners.ts:86 calls it twice per changed file, so a 50-file PR touching an OWNERS with @other/team emits ~100 identical warnings. Warn once per entry.
  2. Docs understate the token requirement. teams.listMembersInOrg needs org-level Members: read; the default GITHUB_TOKEN has no org permissions, so with the stock prow-bot.yml every group silently expands to nobody. docs/commands.md should say outright that GITHUB_TOKEN will not do and the token secret (PAT read:org / App Organization → Members: read) is required; docs/prow-for-your-org.md:189-191 (the "OWNERS_ALIASES … not supported" paragraph) and the day-one-surprises table should point at groups too.

Suggestions

  1. rootOwnersIncludes / assertRootOwner now call the teams API before checking plain logins; under authorization: trusted that is every command. Check direct membership first, expand only on a miss.
  2. Only expandGroups is unit-tested; nothing covers expandOwnersSet, the pullRequestOwners wiring or the auth.ts changes, and there is no bundle scenario. Add one routing GET /orgs/{org}/teams/{slug}/members through dist/index.js.
  3. @alice (no slash) is kept as a literal login that can never match. Now that @ is meaningful, a malformed entry deserves a warning.

…stings

- expandGroups warned on every call, and the per-file expansion calls it
  twice per changed file, so one foreign-org group in a root OWNERS file
  produced a warning per file per role. Warn once per entry per run.
- An `@…` entry that is not `@<org>/<team-slug>` used to be kept as a
  literal login that can never match; drop it with a warning.
- ownersInclude answers a directly listed login before reading any team,
  so the root-OWNERS authorization path makes no teams-API call in the
  common case.
- Docs: the default GITHUB_TOKEN has no organization permissions, so
  groups need the `token` secret with Members: read; say so in commands,
  installing and the day-one surprises table, and point the
  OWNERS_ALIASES note at groups.
- Tests: unit cases for the dedupe, the malformed entry, expandOwnersSet
  and ownersInclude; a bundle file driving all four arms through
  dist/index.js. dist/ repacked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
@mrbobbytables

Copy link
Copy Markdown
Member Author

Pushed d58e18f addressing all five review points:

  1. Warnings are deduped per entry per run (warnedEntries, cleared by resetOwnersCaches).
  2. Docs: docs/commands.md now states the default GITHUB_TOKEN cannot read teams and names the token secret / Members: read requirement; docs/installing.md token row and docs/prow-for-your-org.md (OWNERS_ALIASES note + day-one surprises table) updated.
  3. New ownersInclude checks a direct listing before any teams-API call; assertRootOwner / rootOwnersIncludes use it.
  4. Tests: unit cases for the dedupe, the malformed entry, expandOwnersSet and ownersInclude; new __tests__/bundle/ownersGroups.test.ts drives team-authorized /lgtm on a PR (team read once across 2 files × 2 roles, other-org + malformed entries warn once), on an issue via root OWNERS, the direct-listing short-circuit (no team read), and a 403 team read (refused, one warning) through dist/index.js.
  5. @alice (no slash) is dropped with OWNERS: ignoring @alice: a group is written @<org>/<team-slug>.

npm run all → exit 0: build, lint (1 pre-existing warning in cc.ts), pack, 157 files / 2094 tests pass. dist/ repacked.

The previous repack came from a node_modules that still held js-yaml
5.4.2; CI's fresh npm ci bundles 5.4.3 (#440), so the drift gate failed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Bob Killen <bkillen@linuxfoundation.org>
@mrbobbytables
mrbobbytables merged commit 379e1b5 into main Oct 15, 2026
7 checks passed
jeefy added a commit that referenced this pull request Oct 16, 2026
…OWNERS approver case (#491)

- sweepSharedMemos.test.ts held one case and copies of sweepArms'
  forkPr/runSweep and of helpers.ts routeOwners. Move the case into
  sweepArms.test.ts, built from that file's forkPr/routeList/runSweep and
  routeOwners; every assertion is kept and it still alone reaches the
  tokenIdentity and baseBranchTip memo-hit arms.
- authorizationPolicyArms: since #486 the approver case reaches the same
  ownersInclude arm as the reviewer case above it, and the regression it
  guards (rootOwnersIncludes reading reviewers only) fails
  auth.test.ts 'policyAllows a root OWNERS approver'.

E2E line and branch-arm sets are unchanged.

Signed-off-by: Jeffrey Sica <me@jeefy.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature Categorizes issue or PR as related to a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for GitHub Groups to the Approve plugin

1 participant