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
42 changes: 42 additions & 0 deletions .github/workflows/pr-approved-issue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: 'PR Approved Issue Check (Reusable)'
# Every PR from an external contributor is converted to a draft if its
# description does not link an approved issue assigned to the PR author.
# Repository owners, org members, collaborators and bots bypass this check.

on:
workflow_call:
inputs:
ref:
description: 'Git ref (branch, tag, or commit SHA) of shared-workflows to check out'
required: false
type: string
default: 'main'

permissions:
contents: write # Needed, with pull-requests: write, to toggle draft state.
issues: read
pull-requests: write

jobs:
approved-issue:
# Bypass maintainers, collaborators and bots (e.g. dependabot).
if: >-
github.event.pull_request.user.type != 'Bot' &&
!contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
steps:
- name: 'Check out shared workflows repo'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: 'keras-team/shared-workflows'
ref: '${{ inputs.ref }}'
persist-credentials: false
path: '.shared-workflows'

- name: 'Check PR approved issue'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: '${{ secrets.GITHUB_TOKEN }}'
script: |
const prApprovedIssue = require('./.shared-workflows/scripts/pr-approved-issue.js');
await prApprovedIssue({ github, context, core });
51 changes: 51 additions & 0 deletions docs/pr_policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Keras Pull Request Policy

To keep review bandwidth focused on high-leverage improvements and prevent duplicate work, external pull requests across `keras-team` repositories require a **linked, approved issue assigned to the PR author**.

> **NOTE:** We're constantly evolving this policy for optimal project velocity. If you have any feedback, please chime in on [keras#23601](https://github.com/keras-team/keras/issues/23601).

## Overview of Requirements

1. **Open or find an issue first**:
- Before opening a pull request, find an existing issue or open a new issue describing the bug or feature.
- If an issue is open for contributions, comment on the issue to request assignment.
2. **Wait for issue assignment**:
- Once a maintainer approves the direction and **assigns the issue to you**, you can proceed with opening a PR.
3. **Link the assigned issue in your PR description**:
- Include a reference to the issue in your PR description (for example, `Fixes #xxx` under the `## Approved issue link` section).
4. **Complete the Contributor Agreement checklist**:
- Check all required boxes in the PR description's Contributor Agreement section.
5. **Stay responsive during review**:
- You may have up to **3 PRs** ready for review at a time (additional PRs should remain in draft).
- Feel free to run `/gemini review` on your PR to catch minor issues while waiting for a maintainer review.
- PRs with no activity for 7 days will be marked as stale and closed after 7 additional days.

---

## Automated Enforcement (`pr-approved-issue`)

Our automated workflow checks every pull request from external contributors when a PR is `opened`, `edited`, `reopened`, or marked `ready_for_review`:

- **Who is checked**: All external contributors (`FIRST_TIME_CONTRIBUTOR`, `CONTRIBUTOR`, `NONE`). Repository owners (`OWNER`), organization members (`MEMBER`), collaborators (`COLLABORATOR`), and bots (`Bot` / `[bot]`) are exempt.
- **How issues are matched**:
- The workflow strips HTML comments (`<!-- ... -->`) from the PR description and scans for issue references belonging to the same repository (`#xxx`, `owner/repo#xxx`, or `https://github.com/owner/repo/issues/xxx`).
- Pull request numbers are ignored — the linked number must be an **issue** in the repository, and your GitHub username must be in the issue's **Assignees** list.
- **Draft state behavior**:
- **When the check fails**: If your PR does not link an issue assigned to you and is not currently a draft, the workflow automatically converts the PR to **Draft**, posts a comment explaining how to resolve it, and fails the check.
- **When the check passes**: Once you link an issue assigned to you in the PR description, the status check passes (`✅`). If your PR was in **Draft**, it stays in draft until you click **Ready for review** yourself.

---

## FAQ

### My PR was converted to a draft — how do I get it reviewed?

1. Ensure an issue for your change exists in the repository and has been **assigned to you** by a maintainer.
2. Edit your PR description to link that issue (e.g., `Fixes #xxx` under `## Approved issue link`).
3. Wait for the `PR approved issue check` status to pass (`✅`), then click **Ready for review** on your PR.

### Multiple people offered to send a PR for an issue — how is assignment decided?

1. In general, assignment is first-come, first-served, with the reporter of the issue given the first opportunity to submit a PR.
2. Maintainers may author PRs directly based on urgency and priority.
3. If an issue is assigned to a contributor and there is no PR or activity within 7 days, it may be reassigned to the next volunteer.
171 changes: 171 additions & 0 deletions scripts/pr-approved-issue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2026 The Keras Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =============================================================================
*/

/**
* Enforce that external PRs link an approved issue assigned to the author.
*
* - Strips HTML comments from the PR description and scans for issue references
* (`#xxx`, `owner/repo#xxx` or a full issue URL).
* - Passes when at least one referenced issue exists in this repo and has the
* PR author as an assignee.
* - When the check fails: converts non-draft PRs to draft, posts a comment (on
* `opened` or when converting to draft), and marks the check as failed.
*
* Maintainers, collaborators and bots are skipped by the workflow `if:`.
*/

const POLICY_DOC_URL =
"https://github.com/keras-team/shared-workflows/blob/main/docs/pr_policy.md";
const BYPASS_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"];
// Historical RFC announcement issue in keras-team/keras that was linked in PR templates.
const IGNORED_REPO_ISSUES = {
"keras-team/keras": new Set([23601]),
};

module.exports = async function prApprovedIssue({ github, context, core }) {
const payloadPr = context.payload.pull_request;
if (!payloadPr) {
core.info("Not a pull request payload.");
return;
}

const { owner, repo } = context.repo;
const action = context.payload.action;

// Fetch live PR state so re-running a failed workflow job reads the latest
// PR description and draft status rather than the frozen event payload.
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: payloadPr.number,
});
const author = pr.user.login;

// Defence in depth: the workflow `if:` already filters these out.
if (
pr.user.type === "Bot" ||
author.toLowerCase().endsWith("[bot]") ||
BYPASS_ASSOCIATIONS.includes(pr.author_association)
) {
core.info(`Skipping #${pr.number}: ${author} is ${pr.author_association}.`);
return;
}

const body = pr.body || "";

// 1. Find issues referenced in the description that are assigned to the author.
const referenced = findIssueNumbers(body, owner, repo);
const assigned = [];
const notAssigned = [];
for (const number of referenced) {
try {
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number });
if (issue.pull_request) continue; // A PR reference, not an issue.
const assignees = (issue.assignees || []).map((a) => a.login.toLowerCase());
(assignees.includes(author.toLowerCase()) ? assigned : notAssigned).push(number);
} catch (err) {
core.info(`Could not fetch issue #${number}: ${err.message}`);
}
}

if (assigned.length > 0) {
core.info(
`Approved issue check passed for #${pr.number}: ${assigned.map((n) => `#${n}`).join(", ")} assigned to @${author}.`
);
return;
}

// 2. Check failed: convert non-draft PRs to draft.
const wasNotDraft = !pr.draft;
if (wasNotDraft) {
await convertToDraft(github, core, pr);
}

// 3. Post a comment when the PR is opened or when converting a non-draft PR to draft.
if (action === "opened" || wasNotDraft) {
const message = [
`❌ **Approved issue check failed.** This PR has been placed in **draft** until it links an approved issue assigned to @${author}.`,
"",
`Please review the [Keras Pull Request Policy](${POLICY_DOC_URL}). To resolve this:`,
"1. Find or open an issue for this change and ask a maintainer to approve it and assign it to you.",
"2. Link the issue in this PR's description (e.g. `Fixes #xxx`).",
"3. Once this check passes, mark the PR **Ready for review** when it is ready.",
"",
notAssigned.length
? `Referenced issue(s) not assigned to @${author}: ${notAssigned.map((n) => `#${n}`).join(", ")}.`
: "No issue reference was found in the description.",
].join("\n");

await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: message,
});
}

core.setFailed(`No approved issue assigned to ${author} is linked in #${pr.number}.`);
};

/** Collect issue numbers referenced in `body` (excluding HTML comments) that belong to this repo. */
function findIssueNumbers(body, owner, repo) {
// Strip HTML comments so placeholders like <!-- Fixes #123 --> are never matched.
const uncommented = body.replace(/<!--[\s\S]*?-->/g, "");
const repoKey = `${owner}/${repo}`.toLowerCase();
const ignored = IGNORED_REPO_ISSUES[repoKey] || new Set();

const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(`https?://github\\.com/${escaped}/issues/(\\d+)`, "gi"),
new RegExp(`(?:^|[^\\w/])${escaped}#(\\d+)`, "gi"),
// Bare `#123`, but not `owner/repo#123` of some other repo.
/(?:^|[^\w/])#(\d+)\b/g,
];
const numbers = new Set();
for (const pattern of patterns) {
for (const match of uncommented.matchAll(pattern)) {
const num = Number(match[1]);
if (!ignored.has(num)) {
numbers.add(num);
}
}
}
return [...numbers];
}

async function convertToDraft(github, core, pr) {
try {
await github.graphql(
`mutation($id: ID!) {
convertPullRequestToDraft(input: {pullRequestId: $id}) {
pullRequest { isDraft }
}
}`,
{ id: pr.node_id }
);
core.info(`Converted #${pr.number} to draft.`);
return true;
} catch (err) {
const lines = [`Could not convert #${pr.number} to draft: ${err.message}`];
if (/not accessible by integration/i.test(err.message)) {
lines.push("Toggling draft state needs `contents: write` and `pull-requests: write`.");
}
core.warning(lines.join("\n"));
return pr.draft;
}
}
Loading
Loading