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
8 changes: 8 additions & 0 deletions .changes/pr-pipeline/feat-owner-pr-feedback-7d3a91c2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
---

### Added

- `address-feedback` also serves an adopted owner PR: one a `kind: human` account on the base's trusted-actors list opened, under an `adoption` section, with no `Hold:` body line, once the PR and its head commit are `adoption.idle-hours` old, the rule fix-ci adopts by. Its replies post as the lanes App, so the owner's babysit worker tier can tell a lane answer from the person's and resolve the outdated threads the lane answered ([#10763](https://github.com/melodic-software/claude-code-plugins/issues/10763)). The half-hourly sweep sends one such idle PR per tick whose reviewed head has no round yet, since its review usually finishes while the session is still active; it decides such a head only within two hours of the PR turning idle, and clears a failing address-feedback stop once the owner has resolved every thread. Lanes App PRs, and every other author's PRs, are dispatched as before.
- On an owner PR, `publish-feedback` posts each answer and leaves the thread open, since the lanes App cannot resolve a thread on a PR it did not open, and the select job skips a thread the person wrote or answered with no stop instead of escalating `feedback-untaken-threads`.
41 changes: 39 additions & 2 deletions .github/scripts/pr-unblock/address-feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ export async function select({
trustedHumans,
humanGatedLabel = "needs-human",
maxRounds = DEFAULT_MAX_ROUNDS,
adoption = NO_ADOPTION,
}) {
const [pull, files, issueComments, reviews, reviewComments, threads] = await Promise.all([
api.getPull(pr),
Expand Down Expand Up @@ -651,7 +652,15 @@ export async function select({
return { decision: "skip", skipReason: "awaiting-human", threads: kept };
}
const stale = new Set(staleVerifyNotes(threads, headSha));
return threads.some((thread) => thread.isResolved === false && !stale.has(thread.id))
const untaken = threads.some((thread) => thread.isResolved === false && !stale.has(thread.id));
// On an adopted owner PR (ownerAuthored), a thread the lane does not take
// is the owner's (one they wrote, answered, or a lane answer the lanes App
// cannot resolve there), and so no stop: it still holds the merge until it
// is resolved. Any other person's PR still escalates.
if (untaken && ownerAuthored(pull, adoption)) {
return { decision: "skip", skipReason: "not-applicable", threads: kept };
}
return untaken
? { decision: "escalate", escalationReason: "feedback-untaken-threads", threads: kept }
: { decision: "skip", skipReason: "not-applicable", resolved: true, threads: kept };
}
Expand Down Expand Up @@ -1088,6 +1097,33 @@ export function trustedHumansFrom(trustedActorsPath) {
);
}

// No owner is adopted unless the base config's `adoption` section sets
// idle-hours (pr-pipeline.yaml).
export const NO_ADOPTION = Object.freeze({ ownerIds: Object.freeze(new Set()), idleHours: undefined });

// An owner PR: one a person on the base's trusted-actors list opened (by id,
// kind human), under an `adoption` section. Its rounds post as the lanes App
// like a lanes App PR's, so a lane answer is told apart from the person's.
export const ownerAuthored = (pull, adoption = NO_ADOPTION) =>
adoption.idleHours !== undefined && pull.user?.type === "User" && adoption.ownerIds.has(pull.user?.id);

// The owners whose PRs a round serves: the base trusted-actors list's people,
// under the base config's `adoption` section. An unreadable section adopts no
// owner, and lanes App PRs proceed.
export async function ownerAdoption(env, log = () => {}) {
try {
const { adoptionFromBase } = await import(new URL("../../actions/resolve-config/resolve-config.mjs", import.meta.url).href);
const idleHours = adoptionFromBase({ BASE_PATH: env.BASE_PATH || ".base" })["idle-hours"];
if (idleHours === undefined) {
return NO_ADOPTION;
}
return { ownerIds: trustedHumansFrom(env.TRUSTED_ACTORS || `${env.BASE_PATH || ".base"}/.github/standards/trusted-actors/trusted-actors.json`), idleHours };
} catch (error) {
log(`::warning::cannot read the base adoption section or trusted-actors list (${error.message}); no owner PR is served this run`);
return NO_ADOPTION;
}
}

// The reviewer bot's id comes from the base's trusted-actors list, by login.
export function reviewerIdFrom(trustedActorsPath, login = "claude[bot]") {
const actor = readActors(trustedActorsPath).find((row) => row.login === login && row.kind === "bot");
Expand Down Expand Up @@ -1121,7 +1157,7 @@ export async function main(argv, env, { api, log = (line) => console.log(line) }
fetch: await countedFetch(),
});
const output = (key, value) => appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
const selectThreads = () => {
const selectThreads = async () => {
need(env, ["TRUSTED_ACTORS"]);
return select({
api: client,
Expand All @@ -1134,6 +1170,7 @@ export async function main(argv, env, { api, log = (line) => console.log(line) }
trustedHumans: trustedHumansFrom(env.TRUSTED_ACTORS),
humanGatedLabel: humanGatedLabelFrom(env.TRACKER_CONFIG || ".base/.work-item-tracker.json"),
maxRounds: maxRoundsFrom(env.REVIEW_ROUNDS),
adoption: await ownerAdoption(env, log),
});
};
if (command === "select" && env.VERIFY_PASS_HEAD) {
Expand Down
27 changes: 26 additions & 1 deletion .github/scripts/pr-unblock/address-feedback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function fakeApi(overrides = {}) {
return {
posted,
permissionReads,
getPull: async () => ({ number: 42, labels: (overrides.labels ?? []).map((name) => ({ name })) }),
// The lanes App opened the PR unless the case names another opener.
getPull: async () => ({ number: 42, user: overrides.opener ? { id: overrides.opener, type: "User" } : { id: APP, type: "Bot" }, labels: (overrides.labels ?? []).map((name) => ({ name })) }),
async getPermission(login) {
permissionReads.push(login);
return (overrides.permissions ?? { "kyle-sexton": "write" })[login] ?? "none";
Expand Down Expand Up @@ -1619,3 +1620,27 @@ test("scanRefusal reads a patch and an output marker together as output, in eith
}
}
});

// On an adopted owner PR, an unresolved thread the lane does not take is the
// owner's: one they wrote or answered, or a lane answer the lanes App cannot
// resolve there. It is no stop, and it does not read as resolved. Any other
// person's PR, or an owner's with no adoption section, still escalates.
test("on an adopted owner PR an untaken thread skips with no stop; elsewhere it still escalates", async () => {
const threads = { reviewComments: [comment(100, REVIEWER), comment(101, HUMAN)], threads: [thread("PRRT_1", [100, 101])] };
const adoption = { ownerIds: new Set([HUMAN]), idleHours: 4 };
const adopted = (api, set = adoption) => select({ api, pr: 42, headSha: HEAD, repository: "o/r", appId: APP, reviewerId: REVIEWER, trustedHumans: new Set([HUMAN]), adoption: set });
const owner = await adopted(fakeApi({ ...threads, opener: HUMAN }));
assert.equal(owner.decision, "skip");
assert.equal(owner.skipReason, "not-applicable");
assert.notEqual(owner.resolved, true);
assert.deepEqual(owner.threads, []);
assert.equal((await adopted(fakeApi(threads))).escalationReason, "feedback-untaken-threads", "a lanes App PR");
assert.equal((await adopted(fakeApi({ ...threads, opener: STRANGER }))).escalationReason, "feedback-untaken-threads", "a stranger's PR");
assert.equal((await run(fakeApi({ ...threads, opener: HUMAN }))).escalationReason, "feedback-untaken-threads", "an owner PR with no adoption section");
});

test("on an owner PR a thread a person opened is never kept", async () => {
const result = await run(fakeApi({ opener: HUMAN, reviewComments: [comment(100, HUMAN)], threads: [thread("PRRT_1", [100])] }));
assert.deepEqual(result.threads, []);
assert.notEqual(result.decision, "proceed");
});
11 changes: 8 additions & 3 deletions .github/scripts/pr-unblock/adjudicate-feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export const MAX_THREADS = 3;
// human verdict settles it, so a model never closes a thread the lane did not
// dispute (settleDisputes).
export const DISPUTED = Object.freeze(["INCORRECT", "UNCERTAIN", "VALID-defer"]);
// What a resolveAfterReply stand-in returns when the lanes App answered a
// thread it cannot resolve (a PR it did not open): handled, and still open.
export const LEFT_OPEN = "left-open";
export const VERDICTS = ["lane", "reviewer", "human"];
export const QUESTIONS = ["intent", "design", "security"];
// The first line of each adjudication reply the lanes App posts: this, or
Expand Down Expand Up @@ -341,7 +344,8 @@ export const FINDING_STANDS_SKIP = "finding-stands";
// a security-review finding rated below SECURITY_PERSON_SEVERITY, which is
// feedback-unadjudicated;
// - lane: on an INCORRECT reply, the fixed reply, then the thread is
// resolved; on an UNCERTAIN or VALID-defer reply (the lane could not tell,
// resolved (left open, as LEFT_OPEN, where the lanes App cannot resolve
// it); on an UNCERTAIN or VALID-defer reply (the lane could not tell,
// or agreed the finding holds) no reply, the thread stays open (the
// ruleset's thread resolution still holds the merge), and
// feedback-unadjudicated;
Expand Down Expand Up @@ -445,12 +449,13 @@ export async function settleDisputes({ api, pr, adjudicatedSha, posted, replies,
throw new Error(`the adjudication reply on ${thread} did not land on its thread`);
}
if (verdict.verdict === "lane") {
if (!(await resolveAfterReply(api, thread, [...before.commentIds, answer.id], headMoved))) {
const resolved = await resolveAfterReply(api, thread, [...before.commentIds, answer.id], headMoved);
if (!resolved) {
log(`${thread}: the finding does not hold, but the thread was left open: it or the PR head changed`);
raise("feedback-partial");
continue;
}
log(`${thread}: the finding does not hold; resolved`);
log(`${thread}: the finding does not hold; ${resolved === LEFT_OPEN ? "left open for the person" : "resolved"}`);
} else {
log(`${thread}: the finding stands`);
stands = true;
Expand Down
Loading
Loading