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
20 changes: 19 additions & 1 deletion .github/scripts/gate_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SummaryContext:
output_path: Path | None
python_required: bool = True
docs_guard_result: str = "success"
test_quality_result: str = "skipped"


@dataclass(slots=True)
Expand Down Expand Up @@ -243,6 +244,7 @@ def _append_job_table(
job_results: Mapping[str, Iterable[str]],
docs_guard_result: str,
docker_result: str,
test_quality_result: str,
) -> None:
lines.append("")
lines.append("| Job | Result |")
Expand All @@ -252,6 +254,7 @@ def _append_job_table(
lines.append(f"| {job_name} | {_friendly(result)} |")
lines.append(f"| docs-guard | {_friendly(docs_guard_result)} |")
lines.append(f"| docker-smoke | {_friendly(docker_result)} |")
lines.append(f"| test-quality | {_friendly(test_quality_result)} |")


def _active_lines(
Expand All @@ -264,9 +267,10 @@ def _active_lines(
job_results: Mapping[str, list[str]],
docs_guard_result: str = "success",
docker_result: str = "skipped",
test_quality_result: str = "skipped",
) -> list[str]:
lines = ["### Gate status", *table]
_append_job_table(lines, job_results, docs_guard_result, docker_result)
_append_job_table(lines, job_results, docs_guard_result, docker_result, test_quality_result)

lint_status, lint_detail = _aggregate(lint_entries)
type_status, type_detail = _aggregate(type_entries)
Expand Down Expand Up @@ -334,13 +338,15 @@ def summarize(context: SummaryContext) -> SummaryResult:
job_results,
docs_guard_result,
context.docker_result,
context.test_quality_result,
)

state = "success"
description = "All Gate checks succeeded."

python_result = _normalize(context.python_result or "success")
docker_result_norm = _normalize(context.docker_result or "skipped")
test_quality_result = _normalize(context.test_quality_result or "skipped")
cosmetic_failure = False
failure_checks: tuple[str, ...] = ()
format_failure = False
Expand Down Expand Up @@ -378,10 +384,20 @@ def summarize(context: SummaryContext) -> SummaryResult:
elif not context.docker_changed:
lines.append("- Docker smoke skipped: no Docker-related changes detected.")

if state == "success":
if test_quality_result == "cancelled":
state = "pending"
description = "Test-quality cancelled; waiting for rerun."
elif test_quality_result not in ("success", "skipped"):
state = "failure"
description = f"Test-quality result: {test_quality_result}."

adjusted_lines = []
for line in lines:
if line.startswith("| docker-smoke"):
adjusted_lines.append(f"| docker-smoke | {_friendly(docker_result_norm)} |")
elif line.startswith("| test-quality"):
adjusted_lines.append(f"| test-quality | {_friendly(test_quality_result)} |")
else:
adjusted_lines.append(line)

Expand Down Expand Up @@ -409,6 +425,7 @@ def build_context() -> SummaryContext:
python_result = os.environ.get("PYTHON_RESULT") or "skipped"
docs_guard_result = os.environ.get("DOCS_GUARD_RESULT") or "success"
docker_result = os.environ.get("DOCKER_RESULT") or "skipped"
test_quality_result = os.environ.get("TEST_QUALITY_RESULT") or "skipped"
docker_changed = _normalize(os.environ.get("DOCKER_CHANGED"), "false") == "true"
python_required = _normalize(os.environ.get("PYTHON_REQUIRED"), "true") == "true"
artifacts_root = Path(os.environ.get("GATE_ARTIFACTS_ROOT", "gate_artifacts"))
Expand All @@ -422,6 +439,7 @@ def build_context() -> SummaryContext:
python_result=python_result,
docs_guard_result=docs_guard_result,
docker_result=docker_result,
test_quality_result=test_quality_result,
docker_changed=docker_changed,
artifacts_root=artifacts_root,
summary_path=summary_path,
Expand Down
137 changes: 137 additions & 0 deletions .github/scripts/runtime_ac_merge_guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use strict';

const RUNTIME_AC_REQUIRED_LABELS = new Set([
'runtime-ac',
'runtime-verification',
'acceptance-criteria',
'verification-spec',
'verification-plan',
'ac-checks',
'runtime-checks',
]);

function labelName(label) {
if (typeof label === 'string') {
return label;
}
if (label && typeof label.name === 'string') {
return label.name;
}
return '';
}

function normalizeLabelName(label) {
return labelName(label).trim().toLowerCase();
}

function runtimeAcRequirement(labels = []) {
const matched = [];
const seen = new Set();

for (const label of labels || []) {
const normalized = normalizeLabelName(label);
if (!normalized) {
continue;
}
const colonIndex = normalized.indexOf(':');
const suffix =
colonIndex >= 0 && colonIndex < normalized.length - 1
? normalized.slice(colonIndex + 1).trim()
: '';
if (
RUNTIME_AC_REQUIRED_LABELS.has(normalized) ||
(suffix && RUNTIME_AC_REQUIRED_LABELS.has(suffix))
) {
if (!seen.has(normalized)) {
matched.push(normalized);
seen.add(normalized);
}
}
}

return {
required: matched.length > 0,
labels: matched,
};
}

function hasRuntimeAcRequirement(labels = []) {
return runtimeAcRequirement(labels).required;
}

// Workflow callers should pass the withRetry function produced by createTokenAwareRetry.
async function fetchPullRequestLabels({ github, owner, repo, prNumber, withRetry }) {
if (!github || !github.rest || !github.rest.issues) {
throw new Error('GitHub client is required to evaluate runtime AC merge labels.');
}
const call = (client = github) =>
client.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});

try {
const response = withRetry ? await withRetry(call) : await call();
return Array.isArray(response && response.data) ? response.data : [];
Comment on lines +63 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the runtime_ac_merge_guard.js file
find . -name "runtime_ac_merge_guard.js" -type f

Repository: stranske/Collab-Admin

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Read the actual implementation of fetchPullRequestLabels
cat -n ".github/scripts/runtime_ac_merge_guard.js"

Repository: stranske/Collab-Admin

Length of output: 4591


🏁 Script executed:

#!/bin/bash
# Check how GitHub client paginate is used in existing workflows
rg "\.paginate\(" --type js -A 3 -B 1

Repository: stranske/Workflows

Length of output: 44


🌐 Web query:

GitHub REST API listLabelsOnIssue pagination behavior per_page limit

💡 Result:

The GitHub REST API endpoint for listing labels on an issue (GET /repos/{owner}/{repo}/issues/{issue_number}/labels) supports pagination via the per_page query parameter [1]. The per_page parameter allows you to control the number of results returned per page, with a maximum limit of 100 [1]. By default, this parameter is set to 30 if not specified [1]. For more extensive data retrieval, you can navigate through the paginated results using the page parameter or by utilizing the link header provided in the API response [1][2][3]. Summary of parameters: - per_page: The number of results to return per page. The minimum value is 1, and the maximum is 100 [1][4]. The default is 30 [1]. - page: The page number of the results to fetch [1]. The default is 1 [1].

Citations:


🏁 Script executed:

#!/bin/bash
# Check how GitHub clients are used; look for pagination patterns
rg "github\." --type js -A 2 -B 1 | head -100

Repository: stranske/Workflows

Length of output: 3267


🏁 Script executed:

#!/bin/bash
# Search for any octokit/github imports or usage patterns
rg "require.*github|import.*github" --type js

Repository: stranske/Workflows

Length of output: 1816


🌐 Web query:

Octokit GitHub client paginate method JavaScript

💡 Result:

The paginate method in the Octokit GitHub client (provided by the @octokit/plugin-paginate-rest plugin) is designed to automatically handle multi-page API responses by fetching subsequent pages until the last page is reached [1][2]. Key aspects of the paginate method: Usage: The method accepts the same arguments as octokit.request, which includes the REST API route as the first argument and parameters as the second [3][1]. You can also pass one of the registered REST endpoint methods (e.g., octokit.rest.issues.listForRepo) as the first argument for better readability and typing [3][4]. Example: const issues = await octokit.paginate("GET /repos/{owner}/{repo}/issues", { owner: "octocat", repo: "hello-world", per_page: 100, }); Mapping and Optimization: You can pass an optional mapping function as the third argument to process or transform the data from each response [3][5]. This is useful for reducing memory usage by storing only the required fields [5][6]. The map function receives a done callback, which allows you to stop pagination prematurely if a certain condition is met [3][6]. Memory Efficiency: While octokit.paginate fetches all items into a single array, for more memory-efficient processing, you can use octokit.paginate.iterator to handle one page of results at a time using an async iterator [4][6]. GraphQL Pagination: Note that Octokit also provides support for GraphQL pagination via octokit.graphql.paginate, which follows a different pattern based on GraphQL cursors [4].

Citations:


Paginate PR label reads to keep the runtime AC guard fail-closed.

fetchPullRequestLabels only reads the first page of results (per_page: 100). The GitHub REST API for listing issue labels is paginated, and if a PR has more than 100 labels, requirement detection will be incomplete, causing the guard to make incorrect merge decisions. Switch to the Octokit paginate() method, which is available on the GitHub client provided by @actions/github.

Suggested patch
 async function fetchPullRequestLabels({ github, owner, repo, prNumber, withRetry }) {
   if (!github || !github.rest || !github.rest.issues) {
     throw new Error('GitHub client is required to evaluate runtime AC merge labels.');
   }
-  const call = (client = github) =>
-    client.rest.issues.listLabelsOnIssue({
+  const call = async (client = github) => {
+    if (typeof client.paginate === 'function') {
+      return client.paginate(client.rest.issues.listLabelsOnIssue, {
+        owner,
+        repo,
+        issue_number: prNumber,
+        per_page: 100,
+      });
+    }
+    const response = await client.rest.issues.listLabelsOnIssue({
       owner,
       repo,
       issue_number: prNumber,
       per_page: 100,
-    });
+    });
+    return Array.isArray(response?.data) ? response.data : [];
+  };
 
   try {
-    const response = withRetry ? await withRetry(call) : await call();
-    return Array.isArray(response && response.data) ? response.data : [];
+    const labels = withRetry ? await withRetry(call) : await call();
+    return Array.isArray(labels) ? labels : [];
   } catch (error) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/runtime_ac_merge_guard.js around lines 63 - 77, The
fetchPullRequestLabels function currently only fetches the first page of labels
(per_page: 100) and does not handle pagination. Instead of using the withRetry
wrapper with a single call to client.rest.issues.listLabelsOnIssue, use the
github.paginate() method to iterate through all pages of results. Replace the
try block logic so that if withRetry is provided, wrap the paginate call with
it; otherwise call paginate directly on the github client, ensuring all labels
are retrieved regardless of count.

} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Unable to evaluate runtime AC merge labels for PR #${prNumber}: ${message}`,
);
}
}

async function assertRuntimeAcMergeAllowed({
github,
core,
owner,
repo,
prNumber,
labels,
withRetry,
source = 'external merge lane',
} = {}) {
if (!owner || !repo || !prNumber) {
throw new Error('owner, repo, and prNumber are required for runtime AC merge guard.');
}

const labelItems = Array.isArray(labels)
? labels
: await fetchPullRequestLabels({ github, owner, repo, prNumber, withRetry });
const requirement = runtimeAcRequirement(labelItems);

if (!requirement.required) {
if (core && typeof core.info === 'function') {
core.info(`Runtime AC merge guard passed for PR #${prNumber}.`);
}
return {
allowed: true,
labels: [],
};
}

const labelList = requirement.labels.join(', ');
const message =
`Runtime AC merge guard blocked ${source} for PR #${prNumber}: ` +
`label(s) ${labelList} require local Orchestrator runtime acceptance checks. ` +
'Merge through Code/Orchestrator/merge_guard.py after the runtime AC spec passes.';

if (core && typeof core.warning === 'function') {
core.warning(message);
}

const error = new Error(message);
error.code = 'runtime_ac_merge_blocked';
error.labels = requirement.labels;
throw error;
}

module.exports = {
RUNTIME_AC_REQUIRED_LABELS,
assertRuntimeAcMergeAllowed,
hasRuntimeAcRequirement,
normalizeLabelName,
runtimeAcRequirement,
};
11 changes: 11 additions & 0 deletions .github/workflows/agents-73-codex-belt-conveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ jobs:
.github/scripts/agent_registry.js
.github/scripts/error_classifier.js
.github/scripts/github-api-with-retry.js
.github/scripts/runtime_ac_merge_guard.js
.github/scripts/token_load_balancer.js
sparse-checkout-cone-mode: false

Expand Down Expand Up @@ -440,6 +441,7 @@ jobs:
script: |
const fs = require('fs');
const retryHelperPath = './.github/scripts/github-api-with-retry.js';
const { assertRuntimeAcMergeAllowed } = require('./.github/scripts/runtime_ac_merge_guard.js');
const retryHelpers = fs.existsSync(retryHelperPath)
? require(retryHelperPath)
: {
Expand All @@ -451,6 +453,15 @@ jobs:
const prNumber = Number('${{ inputs.pr_number }}');
const { owner, repo } = context.repo;
try {
await assertRuntimeAcMergeAllowed({
github,
core,
owner,
repo,
prNumber,
withRetry,
source: 'agents-73-codex-belt-conveyor',
});
await withRetry(() => github.rest.pulls.merge({ owner, repo, pull_number: prNumber, merge_method: 'squash' }));
core.setOutput('merged', 'true');
} catch (error) {
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/agents-81-gate-followups.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,7 @@ jobs:
with:
sparse-checkout: |
.github/scripts/github-api-with-retry.js
.github/scripts/runtime_ac_merge_guard.js
.github/scripts/token_load_balancer.js
sparse-checkout-cone-mode: false
- name: Merge labelled agent PRs (guarded)
Expand All @@ -1563,6 +1564,7 @@ jobs:
const fs = require('fs');
const label = 'automerge';
const retryPath = './.github/scripts/github-api-with-retry.js';
const { assertRuntimeAcMergeAllowed } = require('./.github/scripts/runtime_ac_merge_guard.js');
const { createTokenAwareRetry } = fs.existsSync(retryPath)
? require(retryPath)
: {
Expand Down Expand Up @@ -1731,6 +1733,15 @@ jobs:
note = 'Refusing auto-merge: linked issue/PR has unchecked tasks.';
} else {
try {
await assertRuntimeAcMergeAllowed({
github,
core,
owner,
repo,
prNumber,
withRetry,
source: 'agents-81-gate-followups guarded merge',
});
const response = await withRetry((client) => client.rest.pulls.merge({ owner, repo, pull_number: prNumber, merge_method: 'squash' }));
if (response && response.data && response.data.merged) {
status = 'merged';
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/agents-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ jobs:
github.event_name == 'pull_request_target' &&
steps.eligibility.outputs.should-run == 'true' &&
steps.api_client_base.outputs.available != 'true'
uses: "stranske/Workflows/.github/actions/setup-api-client@6deed4d3937adab2370b4ddf96046ed295efe68f" # v1
uses: "stranske/Workflows/.github/actions/setup-api-client@d68de1904bcdbe16bfe2462b73aa18f41f8a0a47" # v1
with:
secrets: ${{ toJSON(secrets) }}
github_token: ${{ github.token }}
Expand Down Expand Up @@ -180,7 +180,7 @@ jobs:
steps.eligibility.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
steps.api_client_head.outputs.available != 'true'
uses: "stranske/Workflows/.github/actions/setup-api-client@6deed4d3937adab2370b4ddf96046ed295efe68f" # v1
uses: "stranske/Workflows/.github/actions/setup-api-client@d68de1904bcdbe16bfe2462b73aa18f41f8a0a47" # v1
with:
secrets: ${{ toJSON(secrets) }}
github_token: ${{ github.token }}
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

> Read this before changing workflows, prompts, or synced automation files.

## Working Stance — Critical Evaluator (read first)

Your job is correct judgment, not agreement. Evaluate claims, designs, and instructions on the merits before agreeing — including the orchestrator's and the user's. When something is wrong, weaker than an alternative, or missing, say so plainly and lead with the strongest objection. Separate "this is correct" from "I'll do as asked." State your confidence and what would change your mind; flag what you are unsure of. Do not soften a real problem to be agreeable, and do not manufacture disagreement to seem rigorous — calibrated dissent, not maximal.

## This Is A Consumer Repo

Most workflow logic for this repository lives in `stranske/Workflows`. The consumer repo should only carry repo-specific configuration unless it has an explicitly documented exception.
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

> Read this before changing workflows, prompts, or synced automation files.

## Working Stance — Critical Evaluator (read first)

Your job is correct judgment, not agreement. Evaluate claims, designs, and instructions on the merits before agreeing — including the orchestrator's and the user's. When something is wrong, weaker than an alternative, or missing, say so plainly and lead with the strongest objection. Separate "this is correct" from "I'll do as asked." State your confidence and what would change your mind; flag what you are unsure of. Do not soften a real problem to be agreeable, and do not manufacture disagreement to seem rigorous — calibrated dissent, not maximal.

## This Is A Consumer Repo

Most workflow logic for this repository lives in `stranske/Workflows`. The consumer repo should only carry repo-specific configuration unless it has an explicitly documented exception.
Expand Down
Loading
Loading