diff --git a/.github/workflows/setup-labels.yml b/.github/workflows/setup-labels.yml index 803165f48..a415b1691 100644 --- a/.github/workflows/setup-labels.yml +++ b/.github/workflows/setup-labels.yml @@ -52,6 +52,14 @@ jobs: color: '1D76DB', description: 'External plugin submission includes a canvas extension' }, + 'first party': { + color: '1D76DB', + description: 'External plugin repository is owned by a Microsoft or GitHub first-party organization' + }, + 'third party': { + color: '5319E7', + description: 'External plugin repository is owned by another organization' + }, 'hooks': { color: 'C2E0C6', description: 'PR touches hooks' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95e2ac5fd..505e6993d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -253,6 +253,7 @@ The public-submission policy builds on those rules and also requires `license` p 1. **Open an issue** using the external plugin issue form. Automation applies the `external-plugin` and `awaiting-review` labels. 2. **Automated intake validation** checks that the required fields are present and correctly formatted for a GitHub-hosted plugin. Invalid submissions are labeled `requires-submitter-fixes` with a comment explaining what must be fixed before maintainer review. + Intake also checks the repository owner's type using the GitHub API. Organization-owned repositories receive `first party` for the `Microsoft`, `GitHub`, `Azure`, `dotnet`, `MicrosoftDocs`, or `microsoftgraph` organizations (case-insensitive), or `third party` for any other organization. User-owned repositories receive neither label. Ownership labels are informational only and are updated on each intake pass. 3. **Automated quality gates** run after metadata validation: - `vally lint` against the submitted plugin path/ref/sha - install smoke test via Copilot CLI against an ephemeral marketplace entry generated from the submission @@ -289,6 +290,8 @@ Maintainers are responsible for confirming that the submission: ##### Review cadence and label semantics - `external-plugin`: applied to every public external plugin submission and retained on approved issues so scheduled review automation can find them later +- `first party`: the submitted repository is owned by a first-party organization (`Microsoft`, `GitHub`, `Azure`, `dotnet`, `MicrosoftDocs`, or `microsoftgraph`) +- `third party`: the submitted repository is owned by another organization; user-owned repositories have neither ownership label - `awaiting-review`: initial intake state before automation finishes validating the issue - `ready-for-review`: the issue passed automated intake checks and is waiting on a maintainer decision - `requires-submitter-fixes`: automated intake found metadata or quality-gate issues; submitter updates are required before human review diff --git a/eng/external-plugin-intake-state.mjs b/eng/external-plugin-intake-state.mjs index b73c72169..926399f5c 100644 --- a/eng/external-plugin-intake-state.mjs +++ b/eng/external-plugin-intake-state.mjs @@ -7,6 +7,14 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({ color: "1D76DB", description: "External plugin submission includes a canvas extension", }, + "first party": { + color: "1D76DB", + description: "External plugin repository is owned by a Microsoft or GitHub first-party organization", + }, + "third party": { + color: "5319E7", + description: "External plugin repository is owned by another organization", + }, "awaiting-review": { color: "FBCA04", description: "Submission is waiting for automated intake validation", @@ -53,7 +61,14 @@ async function removeLabel({ github, owner, repo, issueNumber, name }) { } } -export async function syncExternalPluginIntakeLabels({ github, owner, repo, issueNumber, desiredLabels }) { +export async function syncExternalPluginIntakeLabels({ + github, + owner, + repo, + issueNumber, + desiredLabels, + syncOwnershipLabels = false, +}) { const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, @@ -63,7 +78,10 @@ export async function syncExternalPluginIntakeLabels({ github, owner, repo, issu const currentManagedLabels = currentLabels .map((label) => label.name) - .filter((name) => EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS.includes(name)); + .filter((name) => + EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS.includes(name) || + (syncOwnershipLabels && ["first party", "third party"].includes(name)) + ); const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name)); const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name)); @@ -137,6 +155,10 @@ export async function applyExternalPluginIntakeEvaluation({ if (evaluation.isCanvasPlugin) { desiredLabels.add("external-plugin-canvas"); } + const ownershipLabel = evaluation.reviewSignals?.repository?.ownershipLabel; + if (ownershipLabel === "first party" || ownershipLabel === "third party") { + desiredLabels.add(ownershipLabel); + } await syncExternalPluginIntakeLabels({ github, @@ -144,6 +166,7 @@ export async function applyExternalPluginIntakeEvaluation({ repo, issueNumber, desiredLabels, + syncOwnershipLabels: true, }); await upsertExternalPluginIntakeComment({ diff --git a/eng/external-plugin-intake-state.test.mjs b/eng/external-plugin-intake-state.test.mjs new file mode 100644 index 000000000..c17a0eebf --- /dev/null +++ b/eng/external-plugin-intake-state.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + applyExternalPluginIntakeEvaluation, + syncExternalPluginIntakeLabels, +} from "./external-plugin-intake-state.mjs"; + +function mockGitHub(currentLabels) { + const labels = new Set(currentLabels); + return { + labels, + async paginate() { + return [...labels].map((name) => ({ name })); + }, + rest: { + issues: { + listLabelsOnIssue() {}, + async addLabels({ labels: addedLabels }) { + for (const name of addedLabels) labels.add(name); + }, + async removeLabel({ name }) { + labels.delete(name); + }, + async listComments() { + return { data: [] }; + }, + async createComment() {}, + }, + }, + }; +} + +for (const state of ["ready-for-review", "requires-submitter-fixes", "awaiting-review", "rejected"]) { + for (const ownershipLabel of ["first party", "third party", null, undefined]) { + test(`intake syncs ${ownershipLabel ?? "no ownership label"} in ${state} state`, async () => { + const github = mockGitHub(["external-plugin", "first party", "third party", "unrelated"]); + const { desiredLabels } = await applyExternalPluginIntakeEvaluation({ + github, + owner: "github", + repo: "awesome-copilot", + issueNumber: 1, + evaluation: { + intakeState: state, + isCanvasPlugin: true, + reviewSignals: { repository: { ownershipLabel } }, + commentMarker: "", + commentBody: "Intake result", + }, + }); + + const expected = ["external-plugin", state, "external-plugin-canvas"]; + if (ownershipLabel) expected.push(ownershipLabel); + assert.deepEqual(desiredLabels, new Set(expected)); + assert.deepEqual(github.labels, new Set([...expected, "unrelated"])); + }); + } +} + +test("state-only label sync preserves ownership labels for maintainer overrides and PR checks", async () => { + const github = mockGitHub(["external-plugin", "requires-submitter-fixes", "first party"]); + await syncExternalPluginIntakeLabels({ + github, + owner: "github", + repo: "awesome-copilot", + issueNumber: 1, + desiredLabels: new Set(["external-plugin", "ready-for-review"]), + }); + + assert.deepEqual(github.labels, new Set(["external-plugin", "ready-for-review", "first party"])); +}); diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index f6bf57430..abede5938 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -75,6 +75,14 @@ const MARKETING_SIGNAL_PATTERNS = Object.freeze([ ["trial", /\bfree\s+trial\b|\bstart\s+your\s+trial\b|\bget\s+started\s+free\b/i], ["checkout", /\bstripe\b|\bcheckout\b|\bsubscribe\s+now\b|\bbuy\s+now\b/i], ]); +const FIRST_PARTY_ORGANIZATIONS = new Set([ + "microsoft", + "github", + "azure", + "dotnet", + "microsoftdocs", + "microsoftgraph", +]); const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ ".github/plugin/plugin.json", ".plugin/plugin.json", @@ -532,6 +540,11 @@ function buildRepositorySignals(repository) { return { status: "pass", + ownershipLabel: repository.owner?.type === "Organization" + ? FIRST_PARTY_ORGANIZATIONS.has(repository.owner.login?.toLowerCase()) + ? "first party" + : "third party" + : null, age_days: ageDays, stars: repository.stargazers_count, watchers: repository.subscribers_count, @@ -1346,7 +1359,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner, } } - if (parsed.plugin?.source?.repo && (parsed.plugin?.source?.ref || parsed.plugin?.source?.sha)) { + if (parsed.plugin?.source?.repo) { repositorySignals = await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source, errors, warnings, token); } const homepageSignals = await inspectHomepage(parsed.plugin?.homepage); diff --git a/eng/external-plugin-intake.test.mjs b/eng/external-plugin-intake.test.mjs index 66f3ee4af..754de360d 100644 --- a/eng/external-plugin-intake.test.mjs +++ b/eng/external-plugin-intake.test.mjs @@ -567,11 +567,12 @@ test("pinned homepage dispatcher connects to the validated address and preserves } }); -function installMockFetch() { +function installMockFetch({ repositoryOwner = { type: "User", login: "octo" }, repositoryStatus = 200 } = {}) { global.fetch = async (url) => { const requestUrl = String(url); if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}`) { return jsonResponse({ + owner: repositoryOwner, private: false, archived: false, created_at: new Date().toISOString(), @@ -579,7 +580,7 @@ function installMockFetch() { subscribers_count: 3, forks_count: 0, open_issues_count: 4, - }); + }, { status: repositoryStatus }); } if ( @@ -602,6 +603,63 @@ function installMockFetch() { }; } +for (const [type, login, expectedLabel] of [ + ["Organization", "github", "first party"], + ["Organization", "Microsoft", "first party"], + ["Organization", "MICROSOFT", "first party"], + ["Organization", "GitHub", "first party"], + ["Organization", "Azure", "first party"], + ["Organization", "azure", "first party"], + ["Organization", "dotnet", "first party"], + ["Organization", "DotNet", "first party"], + ["Organization", "MicrosoftDocs", "first party"], + ["Organization", "microsoftdocs", "first party"], + ["Organization", "microsoftgraph", "first party"], + ["Organization", "MicrosoftGraph", "first party"], + ["Organization", "vendor", "third party"], + ["Organization", "github-vendor", "third party"], + ["User", "microsoft", null], + ["User", "octo", null], +]) { + test(`evaluateExternalPluginIssue classifies ${type} owner ${login}`, async () => { + installMockFetch({ repositoryOwner: { type, login } }); + const result = await evaluateExternalPluginIssue({ + issue: { body: buildIssueBody({ sha: PROVIDED_SHA }) }, + }); + + assert.equal(result.valid, true); + assert.equal(result.reviewSignals.repository.ownershipLabel, expectedLabel); + const mergedResult = applyQualityGateResult(result, { overall_status: "pass" }); + assert.equal(mergedResult.reviewSignals.repository.ownershipLabel, expectedLabel); + }); +} + +for (const status of [404, 503]) { + test(`evaluateExternalPluginIssue does not classify unavailable repository metadata (${status})`, async () => { + installMockFetch({ repositoryStatus: status }); + const result = await evaluateExternalPluginIssue({ + issue: { body: buildIssueBody({ sha: PROVIDED_SHA }) }, + }); + + assert.equal(result.reviewSignals.repository.ownershipLabel, undefined); + if (status === 404) { + assert.match(result.errors.join("\n"), /was not found/); + } else { + assert.match(result.warnings.join("\n"), /could not verify GitHub repository/); + } + }); +} + +test("evaluateExternalPluginIssue classifies ownership even when the submission has no release locator", async () => { + installMockFetch({ repositoryOwner: { type: "Organization", login: "github" } }); + const result = await evaluateExternalPluginIssue({ + issue: { body: buildIssueBody({}) }, + }); + + assert.equal(result.valid, false); + assert.equal(result.reviewSignals.repository.ownershipLabel, "first party"); +}); + test("evaluateExternalPluginIssue fails when ref and sha resolve to different commits", async () => { installMockFetch(); const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: PROVIDED_SHA }) };