mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(ci): assign PR owners through the REST assignees endpoint (#13310)
With the owner-map checkout fixed in #13306, Assign PR owner runs on the ECS pool now reach the assignment and fail there instead: `gh pr edit` exits 1 with "GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)". gh builds before 2.82.1 request projectCards in every `pr edit` lookup, GitHub now answers that field with an error, and the ECS runners carry such a build. Hosted runners ship a newer gh, which is why this never showed before #13270. Add the assignee with POST /repos/{owner}/{repo}/issues/{n}/assignees, the same REST route pr-self-report-label, repo-hygiene and the release-notes classifier already took for labels. The job's pull-requests: write permission covers it. That endpoint silently ignores a login it cannot assign and still answers 201, so read the returned assignee list back (case-insensitively) and record a skip with a warning instead of claiming an assignment that did not happen. The issue script keeps `gh issue edit`: it only requests projectCards when projects are edited.
This commit is contained in:
1 parent
691a374d2a
commit
5ddfacc9d4
2 files changed
+107
-30
No files matched your search
@@ -272,16 +272,25 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// REST, not `gh pr edit`: that command's GraphQL lookup requests
|
||||
// repository.pullRequest.projectCards, which GitHub rejects on the gh
|
||||
// builds that still send that query — the ECS runners' gh does, so every
|
||||
// assignment there exited 1 before mutating anything (see
|
||||
// pr-self-report-label.yml). Unlike `gh pr edit`, this endpoint silently
|
||||
// drops an assignee it cannot accept, so the assignee list it returns is
|
||||
// read back below instead of trusting the exit status.
|
||||
let assignedLogins;
|
||||
try {
|
||||
gh([
|
||||
'pr',
|
||||
'edit',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
repository,
|
||||
'--add-assignee',
|
||||
assignee,
|
||||
]);
|
||||
assignedLogins = gh([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${repository}/issues/${prNumber}/assignees`,
|
||||
'-f',
|
||||
`assignees[]=${assignee}`,
|
||||
'--jq',
|
||||
'.assignees[].login',
|
||||
]).split('\n');
|
||||
} catch (error) {
|
||||
if (/assigning agents is not supported/i.test(error.message)) {
|
||||
// A PR that already carries a coding-agent assignee can only change
|
||||
@@ -304,6 +313,20 @@ function main() {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!assignedLogins.some(
|
||||
(login) => login.toLowerCase() === assignee.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
`::warning::GitHub did not add @${assignee} as an assignee; check their access and ${OWNERS_FILE}.`,
|
||||
);
|
||||
record([
|
||||
`Area: ${area.name}`,
|
||||
`Assignment: skipped — GitHub did not accept @${assignee} as an assignee`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
record([
|
||||
`Area: ${area.name}`,
|
||||
`Assignment: assigned @${assignee} (${loadByOwner.get(assignee)} open)`,
|
||||
|
||||
@@ -229,6 +229,10 @@ function runAssign(dryRun, options = {}) {
|
||||
denyPerm = 'read',
|
||||
editExit = 0,
|
||||
editErr = '',
|
||||
// What the assignees endpoint reports back: 'added' echoes the requested
|
||||
// login, 'lowercase' echoes it in another case, and 'dropped' models
|
||||
// GitHub silently ignoring an assignee it cannot accept.
|
||||
assignResult = 'added',
|
||||
// 'once' fails only the first issue-list lookup (the retry and every
|
||||
// later call succeed); 'always' fails every one.
|
||||
loadFail = '',
|
||||
@@ -282,7 +286,24 @@ case "$*" in
|
||||
*) printf '%s' '5' ;;
|
||||
esac
|
||||
;;
|
||||
"pr edit "*) printf '%s' "$GH_STUB_EDIT_ERR" >&2; exit "$GH_STUB_EDIT_EXIT" ;;
|
||||
*"issues/77/assignees"*)
|
||||
if [ "$GH_STUB_EDIT_EXIT" != 0 ]; then
|
||||
printf '%s' "$GH_STUB_EDIT_ERR" >&2
|
||||
exit "$GH_STUB_EDIT_EXIT"
|
||||
fi
|
||||
# What the --jq filter prints: one login per assignee after the add.
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"assignees[]="*) requested="$(printf '%s' "$arg" | cut -d= -f2)" ;;
|
||||
esac
|
||||
done
|
||||
echo 'someone-else'
|
||||
case "$GH_STUB_ASSIGN" in
|
||||
dropped) ;;
|
||||
lowercase) printf '%s' "$requested" | tr 'A-Z' 'a-z' ;;
|
||||
*) printf '%s' "$requested" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
);
|
||||
@@ -314,6 +335,7 @@ esac
|
||||
GH_STUB_DENY_PERM: denyPerm,
|
||||
GH_STUB_EDIT_EXIT: String(editExit),
|
||||
GH_STUB_EDIT_ERR: editErr,
|
||||
GH_STUB_ASSIGN: assignResult,
|
||||
GH_STUB_LOAD_FAIL: loadFail,
|
||||
GH_STUB_LOAD_ERR: loadErr,
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
@@ -330,16 +352,48 @@ esac
|
||||
};
|
||||
}
|
||||
|
||||
// The assignment goes through the REST assignees endpoint, never
|
||||
// `gh pr edit` (its projectCards lookup fails on older gh builds).
|
||||
const ASSIGN_CALL = /issues\/77\/assignees/;
|
||||
const assignCall = (login) =>
|
||||
new RegExp(
|
||||
`^api -X POST repos/QwenLM/qwen-code/issues/77/assignees -f assignees\\[\\]=${login} `,
|
||||
'm',
|
||||
);
|
||||
|
||||
describe('assign-pr-owner: apply boundary', () => {
|
||||
it('assigns the least loaded eligible owner', () => {
|
||||
const { log, stdout } = runAssign(false);
|
||||
assert.match(log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /assigned @DennisYu07/);
|
||||
// `gh pr edit` requests repository.pullRequest.projectCards, which
|
||||
// GitHub rejects on the gh builds the ECS runners carry.
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
});
|
||||
|
||||
it('matches the returned assignee case-insensitively', () => {
|
||||
const { stdout } = runAssign(false, { assignResult: 'lowercase' });
|
||||
assert.match(stdout, /assigned @DennisYu07/);
|
||||
});
|
||||
|
||||
it('does not claim an assignment GitHub silently dropped', () => {
|
||||
// The REST endpoint answers 201 and simply leaves out a login it cannot
|
||||
// assign, so the exit status alone would report a false success.
|
||||
const { log, stdout, stderr } = runAssign(false, {
|
||||
assignResult: 'dropped',
|
||||
});
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.doesNotMatch(stdout, /assigned @/);
|
||||
assert.match(
|
||||
stdout,
|
||||
/skipped — GitHub did not accept @DennisYu07 as an assignee/,
|
||||
);
|
||||
assert.match(stderr, /::warning::GitHub did not add @DennisYu07/);
|
||||
});
|
||||
|
||||
it('performs no mutation in dry-run mode', () => {
|
||||
const { log, stdout } = runAssign(true);
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /dry-run — would assign @DennisYu07/);
|
||||
});
|
||||
|
||||
@@ -360,7 +414,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const { log, stdout } = runAssign(false, {
|
||||
prJson: JSON.stringify({ ...corePr, author: null }),
|
||||
});
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /skipped — PR author account was deleted/);
|
||||
});
|
||||
|
||||
@@ -372,7 +426,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
assignees: [{ login: owner }],
|
||||
}),
|
||||
});
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /already on the PR/);
|
||||
});
|
||||
|
||||
@@ -380,7 +434,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const { log, stdout } = runAssign(false, {
|
||||
files: 'packages/cli/src/index.ts',
|
||||
});
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /no area path matched/);
|
||||
});
|
||||
|
||||
@@ -395,7 +449,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
assignees: [{ login: owner }],
|
||||
}),
|
||||
});
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /already on the PR/);
|
||||
});
|
||||
|
||||
@@ -403,7 +457,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const { log, stdout } = runAssign(false, {
|
||||
prLatestJson: JSON.stringify({ ...corePr, state: 'MERGED' }),
|
||||
});
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /skipped — PR is not open/);
|
||||
});
|
||||
|
||||
@@ -412,7 +466,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
prLatestJson: JSON.stringify({ ...corePr, headRefOid: 'head-2' }),
|
||||
});
|
||||
assert.equal((log.match(/headRefOid/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /skipped — PR head changed during routing/);
|
||||
});
|
||||
|
||||
@@ -426,7 +480,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
files: 'packages/core/src/skills/loader.ts',
|
||||
});
|
||||
assert.match(stdout, /falling back to core/);
|
||||
assert.match(log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
});
|
||||
|
||||
it('drops an owner who lost push access and falls back to the coarser area', () => {
|
||||
@@ -440,7 +494,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
log,
|
||||
new RegExp(`--add-assignee ${module.owners[0]}\\b`),
|
||||
);
|
||||
assert.match(log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
});
|
||||
|
||||
it('exits without assigning when no owner passes the push-access check', () => {
|
||||
@@ -450,7 +504,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
// never a blind assignment past the collaborator check.
|
||||
for (const permission of ['read', 'error']) {
|
||||
const { log, stdout, stderr } = runAssign(false, { permission });
|
||||
assert.doesNotMatch(log, /pr edit/);
|
||||
assert.doesNotMatch(log, ASSIGN_CALL);
|
||||
assert.match(stdout, /no eligible owner for the matched areas/);
|
||||
if (permission === 'error') {
|
||||
assert.match(stderr, /Cannot verify push access/);
|
||||
@@ -465,16 +519,16 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
});
|
||||
assert.doesNotMatch(stdout, /assigned @/);
|
||||
assert.match(stdout, /token cannot assign/);
|
||||
assert.match(log, /pr edit/);
|
||||
assert.match(log, ASSIGN_CALL);
|
||||
});
|
||||
|
||||
it('re-throws a non-permission pr edit failure instead of swallowing it', () => {
|
||||
it('re-throws a non-permission assignment failure instead of swallowing it', () => {
|
||||
const { log } = runAssign(false, {
|
||||
editExit: 1,
|
||||
editErr: 'network error',
|
||||
expectExit: 1,
|
||||
});
|
||||
assert.match(log, /pr edit/);
|
||||
assert.match(log, ASSIGN_CALL);
|
||||
});
|
||||
|
||||
it('skips gracefully when GitHub refuses agent-assignee edits for App tokens', () => {
|
||||
@@ -493,7 +547,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
stdout,
|
||||
/skipped — token cannot assign PRs with agent assignees/,
|
||||
);
|
||||
assert.match(log, /pr edit/);
|
||||
assert.match(log, ASSIGN_CALL);
|
||||
});
|
||||
|
||||
it('tolerates a transient issue-list failure after one retry', () => {
|
||||
@@ -504,7 +558,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const { log, stdout, stderr } = runAssign(false, { loadFail: 'once' });
|
||||
assert.equal((log.match(/issue list/g) ?? []).length, owners.length + 1);
|
||||
assert.doesNotMatch(stderr, /Cannot read open-issue load/);
|
||||
assert.match(log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /assigned @DennisYu07 \(0 open\)/);
|
||||
});
|
||||
|
||||
@@ -518,7 +572,7 @@ describe('assign-pr-owner: apply boundary', () => {
|
||||
const rotated = pickOwner(owners, degraded, 77);
|
||||
const { log, stdout, stderr } = runAssign(false, { loadFail: 'always' });
|
||||
assert.match(stderr, /Cannot read open-issue load/);
|
||||
assert.match(log, new RegExp(`pr edit 77 .*--add-assignee ${rotated}`));
|
||||
assert.match(log, assignCall(rotated));
|
||||
assert.match(stdout, new RegExp(`assigned @${rotated}`));
|
||||
});
|
||||
});
|
||||
@@ -530,7 +584,7 @@ describe('assign-pr-owner: untrusted filename decoding', () => {
|
||||
previousFiles: ['packages/core/src/old.ts'],
|
||||
});
|
||||
assert.match(log, /previous_filename/);
|
||||
assert.match(log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(log, assignCall('DennisYu07'));
|
||||
assert.match(stdout, /Area: core/);
|
||||
});
|
||||
|
||||
@@ -582,11 +636,11 @@ esac
|
||||
// End to end: a PR carrying only the forged name is skipped instead of
|
||||
// being routed to core through the phantom split entry...
|
||||
const alone = runAssign(false, { fileList: [forged] });
|
||||
assert.doesNotMatch(alone.log, /pr edit/);
|
||||
assert.doesNotMatch(alone.log, ASSIGN_CALL);
|
||||
assert.match(alone.stdout, /no area path matched/);
|
||||
// ...and adding a legit core file routes to core for that file's sake.
|
||||
const mixed = runAssign(false, { fileList: fileNames });
|
||||
assert.match(mixed.log, /pr edit 77 .*--add-assignee DennisYu07/);
|
||||
assert.match(mixed.log, assignCall('DennisYu07'));
|
||||
assert.match(mixed.stdout, /Area: core/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user