This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new a89b26a4a feat(ci): close PRs that skip the issue-first step (#4403)
a89b26a4a is described below

commit a89b26a4a441bf09cfa1930f7b6648f859cb57e9
Author: Justin Mclean <[email protected]>
AuthorDate: Tue Oct 13 12:45:01 2026 +0100

    feat(ci): close PRs that skip the issue-first step (#4403)
---
 .github/workflows/pr-triage-apply.yml | 278 ++++++++++++++++++++++++++++++----
 AGENTS.md                             |   6 +-
 2 files changed, 252 insertions(+), 32 deletions(-)

diff --git a/.github/workflows/pr-triage-apply.yml 
b/.github/workflows/pr-triage-apply.yml
index 6b4702488..42d9f9e1e 100644
--- a/.github/workflows/pr-triage-apply.yml
+++ b/.github/workflows/pr-triage-apply.yml
@@ -62,6 +62,18 @@ name: PR Triage Apply
 #   have no comment to react to and stay log-only.
 #
 # Lifecycle (pull_request_target):
+#   opened (draft or not)  -> comment and close the PR if its description
+#                             has no `Closes #N`, `Fixes #N`, `Resolves #N`
+#                             or `Relates to #N` line naming an issue, or if
+#                             a linked `good first issue` is not assigned to
+#                             its author; bots, authors with write access,
+#                             authors with 3+ merged PRs here and RFC PRs
+#                             are exempt, and nothing below runs for a closed
+#                             PR. Authors whose access cannot be looked up and
+#                             PRs naming more than 5 issues are left for
+#                             manual triage. A good first issue PR that passes
+#                             gets a note for maintainers if its author has
+#                             other PRs open
 #   opened (non-draft)     -> add S-waiting-on-review (only if no S-* present);
 #                             unless the author has repo write access, post
 #                             a one-time welcome comment listing the triage
@@ -383,6 +395,59 @@ jobs:
               'See 
[CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#pr-triage-commands)
 for details.',
             ].join('\n');
 
+            // Posted when a PR is closed on open for having no issue line,
+            // or for linking a good first issue not assigned to its author.
+            const ISSUE_CHECK_MARKER = '<!-- iggy-pr-triage-issue-check -->';
+            const GOOD_FIRST_ISSUE = 'good first issue';
+            // Authors with this many merged PRs here skip the issue check.
+            const REGULAR_CONTRIBUTOR_MERGED_PRS = 3;
+            // More linked issues than this are left for manual triage
+            // rather than looked up one by one.
+            const MAX_LINKED_ISSUES = 5;
+            // RFC PRs are titled `rfc(<scope>): ...`, and rfcs/README.md
+            // opens the tracking issue only after an RFC is accepted.
+            const RFC_TITLE_RE = /^rfc[(:]/i;
+            const ISSUE_FIRST_LINK =
+              'See 
[CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#issue-first).';
+            const missingIssueBody = [
+              ISSUE_CHECK_MARKER,
+              'This PR is closed because its description has no `Closes #N`, ' 
+
+                '`Fixes #N`, `Resolves #N` or `Relates to #N` line naming an 
issue.',
+              '',
+              'Every PR needs an issue that was assigned to you before work 
started. ' +
+                ISSUE_FIRST_LINK,
+            ].join('\n');
+            const unassignedBody = (issues) => [
+              ISSUE_CHECK_MARKER,
+              `This PR is closed because ${issues.map(n => `#${n}`).join(', 
')} ` +
+                `${issues.length === 1 ? 'is' : 'are'} not assigned to you.`,
+              '',
+              'Good first issues are assigned before work starts. Comment on 
an issue, ' +
+                'wait for a maintainer to assign it to you, and then open a 
PR. ' +
+                ISSUE_FIRST_LINK,
+            ].join('\n');
+            // A note for maintainers, not a close: whether a second open PR
+            // is acceptable is their call.
+            const OTHER_PRS_MARKER = '<!-- iggy-pr-triage-other-open-prs -->';
+            const otherOpenPrsBody = (author, prs) => [
+              OTHER_PRS_MARKER,
+              `Note for maintainers: @${author} also has ${prs.map(n => 
`#${n}`).join(', ')} open. ` +
+                'New contributors are asked to keep one open PR at a time. ' +
+                'See 
[CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md).',
+            ].join('\n');
+            // A line starting with the keyword, so prose and the template's
+            // own examples inside its HTML comments do not count. Every
+            // issue on the line counts, as in `Closes #1, fixes #2`. An issue
+            // may be `#N`, a mistyped `##N`, this repo's issue URL, or either
+            // inside a Markdown link.
+            const ISSUE_URL = 
`https://github.com/${context.repo.owner}/${context.repo.repo}/issues/`
+              .replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
+            const ISSUE_LINE_RE =
+              /^[ \t]*(?:closes|fixes|resolves|relates[ \t]+to):?[ 
\t]+(.*)$/gim;
+            const ISSUE_REF_RE = new RegExp(`(?:#+|${ISSUE_URL})(\\d+)\\b`, 
'g');
+            const isGoodFirstIssue = (issue) =>
+              issue.labels.some(l => (typeof l === 'string' ? l : l.name) === 
GOOD_FIRST_ISSUE);
+
             // Retry transient GitHub API failures so a single 502 cannot
             // abort a command. Transient = 5xx, 429, or a network error
             // with no HTTP status. Anything else (404, 403, 422) is not
@@ -554,10 +619,9 @@ jobs:
             // repo, author_association cannot answer this: it reports
             // MEMBER for every org member, not just repo writers, so the
             // collaborator-permission endpoint is the only accurate
-            // signal. On failure return false -- a stray welcome on a
-            // maintainer PR is harmless, a missing one on a contributor
-            // PR defeats the feature.
-            const hasWriteAccess = async (login) => {
+            // signal. Returns null when the lookup fails, so each caller
+            // decides what an unknown answer means.
+            const writeAccess = async (login) => {
               try {
                 const { data } = await withRetry(() =>
                   github.rest.repos.getCollaboratorPermissionLevel({
@@ -568,42 +632,167 @@ jobs:
                 return data.permission === 'write' || data.permission === 
'admin';
               } catch (e) {
                 core.warning(`write-access check failed for ${login}: 
${e.message}`);
+                return null;
+              }
+            };
+            // Unknown counts as no access -- a stray welcome on a
+            // maintainer PR is harmless, a missing one on a contributor
+            // PR defeats the feature.
+            const hasWriteAccess = async (login) => (await writeAccess(login)) 
=== true;
+
+            // Post `body` unless a comment carrying `marker` is already on
+            // the PR, so a manual workflow re-run does not double-post.
+            // Returns true when it posted. Throws on API failure; callers
+            // keep their own error handling. Paginated so the marker is
+            // still found on a PR with more than 100 comments.
+            const postCommentOnce = async (marker, body, label) => {
+              const existing = await withRetry(() => github.paginate(
+                github.rest.issues.listComments,
+                {
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: prNumber,
+                  per_page: 100,
+                },
+              ), `listComments (${label})`);
+              if (existing.some(c => c.body && c.body.includes(marker))) {
                 return false;
               }
+              await withRetry(() => github.rest.issues.createComment({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                issue_number: prNumber,
+                body,
+              }), `${label} createComment`);
+              return true;
             };
 
             // Post WELCOME_BODY once on PR open. Best-effort: a failed
             // post must never fail the run or block labeling, the same
-            // contract as react()/reply(). Idempotent via a listComments
-            // scan for WELCOME_MARKER, so a manual workflow re-run does
-            // not double-post.
+            // contract as react()/reply().
             const postWelcomeOnce = async () => {
               try {
-                // Paginated so the marker is still detected if an Actions
-                // re-run fires after the PR has accumulated more than 100
-                // comments. Without it the welcome would double-post.
-                const existing = await withRetry(() => github.paginate(
-                  github.rest.issues.listComments,
-                  {
-                    owner: context.repo.owner,
-                    repo: context.repo.repo,
-                    issue_number: prNumber,
-                    per_page: 100,
-                  },
-                ), 'listComments');
-                if (existing.some(c => c.body && 
c.body.includes(WELCOME_MARKER))) {
-                  core.info('welcome: already posted, skipping');
+                core.info(await postCommentOnce(WELCOME_MARKER, WELCOME_BODY, 
'welcome')
+                  ? 'welcome: posted'
+                  : 'welcome: already posted, skipping');
+              } catch (e) {
+                core.warning(`welcome comment failed: ${e.message}`);
+              }
+            };
+
+            // When a new contributor opens a good first issue PR while other
+            // PRs of theirs are open, note it for maintainers. Best-effort,
+            // like the welcome: a failure only loses the note.
+            const noteOtherOpenPrs = async (author) => {
+              try {
+                const { data } = await withRetry(() =>
+                  github.rest.search.issuesAndPullRequests({
+                    q: `repo:${context.repo.owner}/${context.repo.repo} is:pr 
is:open author:${author}`,
+                    per_page: 20,
+                  }), `search open PRs ${author}`);
+                const others = data.items.map(i => i.number).filter(n => n !== 
prNumber);
+                if (others.length === 0) {
                   return;
                 }
-                await withRetry(() => github.rest.issues.createComment({
+                if (await postCommentOnce(OTHER_PRS_MARKER, 
otherOpenPrsBody(author, others),
+                  'other open PRs')) {
+                  core.info(`other open PRs: noted ${others.join(', ')}`);
+                }
+              } catch (e) {
+                core.warning(`other open PRs note failed: ${e.message}`);
+              }
+            };
+
+            // Close a just-opened PR whose description has no Closes, Fixes,
+            // Resolves or Relates to line naming an issue, or that links a 
good first
+            // issue not assigned to its author, after saying why. Returns
+            // true when the PR was closed. The issue numbers come from the
+            // description the PR was opened with, so the check does not
+            // depend on when GitHub indexes the links. Fails open: if a
+            // lookup or the comment fails the PR takes the normal path,
+            // because closing a valid PR with no explanation is worse than
+            // missing one.
+            const closeIfIssueCheckFails = async (author, prBody, prTitle) => {
+              try {
+                if (RFC_TITLE_RE.test(prTitle || '')) {
+                  core.info('issue check: skipped, RFC PR');
+                  return false;
+                }
+                // Regular contributors are trusted to link issues their own
+                // way, such as a heading or a PR split from another.
+                const { data: merged } = await withRetry(() =>
+                  github.rest.search.issuesAndPullRequests({
+                    q: `repo:${context.repo.owner}/${context.repo.repo} is:pr 
is:merged author:${author}`,
+                    per_page: 1,
+                  }), `search merged PRs ${author}`);
+                if (merged.total_count >= REGULAR_CONTRIBUTOR_MERGED_PRS) {
+                  core.info(`issue check: skipped, ${author} has 
${merged.total_count} merged PRs`);
+                  return false;
+                }
+                const text = (prBody || '').replace(/<!--[\s\S]*?-->/g, '');
+                const numbers = [...new Set(
+                  [...text.matchAll(ISSUE_LINE_RE)].flatMap(line =>
+                    [...line[1].matchAll(ISSUE_REF_RE)].map(m => 
Number(m[1]))))];
+                if (numbers.length > MAX_LINKED_ISSUES) {
+                  core.info(`issue check: skipped, ${numbers.length} linked 
issues, left for manual triage`);
+                  return false;
+                }
+                const issues = [];
+                for (const number of numbers) {
+                  try {
+                    const { data } = await withRetry(() => 
github.rest.issues.get({
+                      owner: context.repo.owner,
+                      repo: context.repo.repo,
+                      issue_number: number,
+                    }), `issues.get ${number}`);
+                    // The issues API also returns PRs; only issues count.
+                    if (!data.pull_request) {
+                      issues.push(data);
+                    }
+                  } catch (e) {
+                    if (e.status !== 404) {
+                      throw e;
+                    }
+                  }
+                }
+                const login = author.toLowerCase();
+                const goodFirstIssues = issues.filter(isGoodFirstIssue);
+                const unassigned = goodFirstIssues
+                  .filter(i => !(i.assignees || []).some(a => 
a.login.toLowerCase() === login))
+                  .map(i => i.number);
+                let body;
+                if (issues.length === 0) {
+                  body = missingIssueBody;
+                } else if (unassigned.length > 0) {
+                  body = unassignedBody(unassigned);
+                } else {
+                  if (goodFirstIssues.length > 0) {
+                    await noteOtherOpenPrs(author);
+                  }
+                  return false;
+                }
+                await postCommentOnce(ISSUE_CHECK_MARKER, body, 'issue check');
+                await withRetry(() => github.rest.pulls.update({
                   owner: context.repo.owner,
                   repo: context.repo.repo,
-                  issue_number: prNumber,
-                  body: WELCOME_BODY,
-                }), 'welcome createComment');
-                core.info('welcome: posted');
+                  pull_number: prNumber,
+                  state: 'closed',
+                }), 'issue check close');
+                core.info(issues.length === 0
+                  ? 'issue check: closed, no issue line'
+                  : `issue check: closed, ${author} not assigned to 
${unassigned.join(', ')}`);
+                // A close made with GITHUB_TOKEN does not trigger the
+                // closed handler, so clear the S-* labels here. The PR is
+                // already closed, so a failure only leaves a stray label.
+                try {
+                  await replaceStateLabel(null);
+                } catch (e) {
+                  core.warning(`issue check: clearing S-* labels failed: 
${e.message}`);
+                }
+                return true;
               } catch (e) {
-                core.warning(`welcome comment failed: ${e.message}`);
+                core.warning(`issue check failed: ${e.message}`);
+                return false;
               }
             };
 
@@ -628,6 +817,24 @@ jobs:
               }
 
               if (action === 'opened' || action === 'ready_for_review') {
+                const author = pr.user && pr.user.login;
+                const authorIsBot = (pr.user && pr.user.type === 'Bot')
+                  || (author && /\[bot\]$/.test(author));
+                // A human author's write access, checked once on open for
+                // both the issue check and the welcome: true, false, or
+                // null when the lookup failed.
+                const access = action === 'opened' && author && !authorIsBot
+                  ? await writeAccess(author)
+                  : null;
+                // Runs on drafts too: opening a draft still claims the
+                // issue. ready_for_review is not checked, so the issue
+                // line and assignment are judged only as the PR opened.
+                // Only a confirmed contributor is checked: closing a
+                // maintainer's PR on a failed lookup is worse than
+                // missing one.
+                if (access === false && await closeIfIssueCheckFails(author, 
pr.body, pr.title)) {
+                  return;
+                }
                 if (pr.draft) {
                   core.info('lifecycle: draft PR, no label');
                   return;
@@ -642,19 +849,28 @@ jobs:
                 // welcome targets human contributor onboarding and bots
                 // cannot drive triage commands anyway (gated by isBot).
                 if (action === 'opened') {
-                  const author = pr.user && pr.user.login;
-                  const authorIsBot = (pr.user && pr.user.type === 'Bot')
-                    || (author && /\[bot\]$/.test(author));
                   if (!author) {
                     core.info('welcome: skipped, PR has no author');
                   } else if (authorIsBot) {
                     core.info(`welcome: skipped, ${author} is a bot`);
-                  } else if (await hasWriteAccess(author)) {
+                  } else if (access === true) {
                     core.info(`welcome: skipped, ${author} has write access`);
                   } else {
                     await postWelcomeOnce();
                   }
                 }
+                // A queued run can reach here after another run closed
+                // the PR, such as the issue check above. Read the live
+                // state rather than the webhook payload.
+                const { data: livePr } = await withRetry(() => 
github.rest.pulls.get({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  pull_number: prNumber,
+                }), 'pulls.get');
+                if (livePr.state !== 'open') {
+                  core.info('lifecycle: PR is closed, no label');
+                  return;
+                }
                 // Read live labels rather than the webhook-frozen
                 // pr.labels — a comment-driven /author or /ready may have
                 // landed between the lifecycle event and this run. Paginated
diff --git a/AGENTS.md b/AGENTS.md
index bd20c1d7d..e8019e34f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,6 +32,9 @@ ingests from / egresses to external systems via dlopened 
plugins.
 
 ## STOP and ask the user before
 
+- Writing code for an issue not assigned to the user
+  (`gh issue view <N> --json assignees`). If it isn't, or you can't
+  confirm it, they must ask on the issue and wait to be assigned.
 - Bumping `iggy_connector_sdk` MAJOR version or changing any FFI
   signature in `core/connectors/sdk/src/{sink,source}.rs` (breaks
   every pre-built plugin .so).
@@ -48,7 +51,8 @@ ingests from / egresses to external systems via dlopened 
plugins.
 
 Most PRs from people new to Iggy are written with an agent. The person opening 
the PR is responsible for it, and maintainers close PRs that read as a relay 
between reviewer and model. Read the AI Assistance, Bugs Found by an Agent and 
Close Policy sections of CONTRIBUTING.md before helping open a PR.
 
-- The PR must link an issue the user is assigned to. If it doesn't, stop and 
tell them.
+- The PR must link an issue the user was assigned to before any work started. 
See the STOP list above.
+- Fill in every section of the PR template. Under AI Usage, name the agent and 
say what it wrote. Never leave the section out or write "None" when an agent 
wrote any of the change.
 - Run `prek run` before the PR is opened. If prek isn't installed, ask the 
user to install it (`cargo install prek`, then `prek install`). Don't 
substitute the individual checks.
 - If a check can't run, tell the user which one and why, and treat the PR as 
not ready. Never write text in a PR asking a maintainer to run something.
 - The user should write the rationale in their own words and be able to 
explain every change without asking you.

Reply via email to