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
1 change: 1 addition & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3087,6 +3087,7 @@ async function runAgentMaintenancePlanAndExecute(
mergeBlockedSha: pr.mergeBlockedSha,
approvedHeadSha: pr.approvedHeadSha,
authorLogin: pr.authorLogin,
linkedIssues: pr.linkedIssues,
},
});
// Accuracy circuit-breakers (#self-improve / GAP-4): two INDEPENDENT, fail-open precision breakers, chained.
Expand Down
12 changes: 12 additions & 0 deletions src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,17 @@ async function performAction(env: Env, ctx: AgentActionExecutionContext, action:
const login = action.assignee ?? "";
if (!login) return undefined;
const result = await ensurePullRequestAssignee(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, login);
// Best-effort mirror the same assignment onto every linked issue (#priority-linked-issue-gate-ownership):
// gittensor:priority propagation requires the PR author to be the linked issue's own author OR assignee,
// so without this a contributor who picks up a maintainer-authored issue can never actually satisfy that
// check (see maybePlanAssign's doc comment, agent-actions.ts). Independent per-issue call: one issue's
// write failing (permissions, deleted issue, rate limit) must not affect another issue's assignment or the
// PR-assign outcome already computed above.
await Promise.all(
(action.assignLinkedIssues ?? []).map((issueNumber) =>
ensurePullRequestAssignee(env, ctx.installationId, ctx.repoFullName, issueNumber, login).catch(() => undefined),
),
);
if (!result.applied) {
// GitHub silently drops an assignee lacking push/triage access to the repo -- the common case for an
// external contributor. Fall back to a per-login label instead of a comment: ensurePullRequestLabel's
Expand Down Expand Up @@ -959,6 +970,7 @@ export function actionParams(action: PlannedAgentAction): AgentPendingActionPara
...(action.reviewBody !== undefined ? { reviewBody: action.reviewBody } : {}),
...(action.mergeMethod !== undefined ? { mergeMethod: action.mergeMethod } : {}),
...(action.assignee !== undefined ? { assignee: action.assignee } : {}),
...(action.assignLinkedIssues !== undefined ? { assignLinkedIssues: action.assignLinkedIssues } : {}),
...(action.closeComment !== undefined ? { closeComment: action.closeComment } : {}),
...(action.closeReasons !== undefined ? { closeReasons: [...boundStructuredCloseReasonsForPersistence(action.closeReasons)] } : {}),
...(action.expectedHeadSha !== undefined ? { expectedHeadSha: action.expectedHeadSha } : {}),
Expand Down
27 changes: 27 additions & 0 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ export type PlannedAgentAction = {
// contributor. GitHub silently drops an assignee lacking push/triage access rather than erroring, so the
// executor falls back to a per-login label when the real assignment doesn't stick.
assignee?: string;
// For an `assign` action (#priority-linked-issue-gate-ownership): the SAME login also best-effort-assigned to
// each of these linked issue numbers (already capped, see maybePlanAssign). Without this, a contributor is
// assigned to their own PR but never to the issue(s) it closes -- and gittensor:priority propagation
// (resolveIssueLabelsForPropagation, linked-issue-label-propagation-fetch.ts) requires the PR author to be the
// linked issue's own author OR assignee, which an issue-opened-for-open-pickup contributor otherwise never is.
assignLinkedIssues?: number[];
};

// Gate-blocker codes backed by CONCRETE, non-judgment evidence: a committed secret, a deterministic
Expand Down Expand Up @@ -381,6 +387,9 @@ export type AgentActionPlanInput = {
// is harmless -- those all set `conclusion: "skipped"` or hit an earlier short-circuit `return`, so the
// `assign` block below is unreachable from them regardless.
authorLogin?: string | null | undefined;
// The PR's linked/closing issue numbers (#priority-linked-issue-gate-ownership), threaded through ONLY for
// the `assign` disposition below -- same "harmless when absent" reasoning as authorLogin just above.
linkedIssues?: number[] | undefined;
};
};

Expand Down Expand Up @@ -549,21 +558,39 @@ function screenshotTableCloseMessage(reason: string): string {
return `${reason} This is an automated maintenance action.`;
}

// Best-effort assign-to-linked-issue fan-out cap (#priority-linked-issue-gate-ownership): a PR overwhelmingly
// closes 1 (rarely 2-3) issues. `pr.linkedIssues` is already capped much higher (50, MAX_LINKED_ISSUE_NUMBERS)
// for extraction/storage purposes only -- this narrower cap bounds the number of extra GitHub assignee-WRITE
// calls the executor makes for one `assign` action.
const ASSIGN_LINKED_ISSUES_MAX = 10;

/**
* Plan best-effort assignment of the PR's opening contributor (#3182), independent of merge/close/CI outcome.
* MUST run before the CI-pending settle-before-decide return below (#assign-before-ci-pending) — a PR that has
* already been reviewed/evaluated (conclusion isn't "skipped") should get an assignee for triage even while an
* unrelated check is still pending; assign has no bearing on mergeability so it never needs CI to settle first.
* Gated purely on its own `assign` autonomy class, same as every other independent action here.
*
* Also best-effort assigns the SAME contributor to the PR's own linked issues (#priority-linked-issue-gate-
* ownership), capped at ASSIGN_LINKED_ISSUES_MAX. Without this, gittensor:priority propagation could never fire
* in practice for a contributor's PR: resolveIssueLabelsForPropagation only unlocks that label when the PR
* author is the linked issue's own author OR a GitHub assignee of it, but our issues are almost always opened
* for open pickup and rarely formally assigned (see the propagation config's own comment in
* gittensory-repo-focus-manifest.ts) -- so before this, only the PR itself ever got an assignee, never the
* issue it closes.
*/
function maybePlanAssign(actions: PlannedAgentAction[], input: AgentActionPlanInput): void {
const level = resolveAutonomy(input.autonomy, "assign");
if (!isActingAutonomyLevel(level) || !input.pr.authorLogin) return;
const linkedIssues = input.pr.linkedIssues;
actions.push({
actionClass: "assign",
requiresApproval: autonomyRequiresApproval(level),
reason: "auto-assign PR opener",
assignee: input.pr.authorLogin,
...(linkedIssues && linkedIssues.length > 0
? { assignLinkedIssues: linkedIssues.slice(0, ASSIGN_LINKED_ISSUES_MAX) }
: {}),
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,9 @@ export type AgentPendingActionParams = {
mergeMethod?: AutoMergeMethod;
// For an `assign` action (#3182): the GitHub login to assign when a staged action is accepted.
assignee?: string;
// For an `assign` action (#priority-linked-issue-gate-ownership): the linked issue numbers to ALSO assign
// `assignee` to when a staged action is accepted (see PlannedAgentAction.assignLinkedIssues).
assignLinkedIssues?: number[];
closeComment?: string;
// Individual close reasons, persisted for approval-queue replay so the eventual audit row keeps the structured
// reason list rather than only the flattened `reason` field.
Expand Down
41 changes: 41 additions & 0 deletions test/unit/agent-action-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
expect(replayed).toMatchObject({ actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener", assignee: "alice" });
});

it("REGRESSION (#priority-linked-issue-gate-ownership): actionParams round-trips assignLinkedIssues through approval replay", () => {
const assign: PlannedAgentAction = { actionClass: "assign", requiresApproval: true, reason: "auto-assign PR opener", assignee: "alice", assignLinkedIssues: [42, 43] };

const persisted = actionParams(assign);
const replayed = pendingActionToPlanned({ actionClass: "assign", params: persisted, reason: assign.reason });

expect(persisted).toEqual({ assignee: "alice", assignLinkedIssues: [42, 43] });
expect(replayed).toMatchObject({ actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener", assignee: "alice", assignLinkedIssues: [42, 43] });
});

it("actionParams round-trips structured closeReasons so approval replay preserves every close cause", () => {
const closeWithReasons: PlannedAgentAction = {
actionClass: "close",
Expand Down Expand Up @@ -854,6 +864,37 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
expect(audit?.detail).toBe("assignee refused by GitHub — fell back to a by:external-contributor label");
});

it("LIVE assign (#priority-linked-issue-gate-ownership): also assigns the PR's linked issues, not just the PR itself", async () => {
const env = createTestEnv({});
const assign: PlannedAgentAction = { actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener", assignee: "alice", assignLinkedIssues: [42, 43] };
const outcomes = await executeAgentMaintenanceActions(env, ctx({ autonomy: { assign: "auto" } }), [assign]);
expect(ensurePullRequestAssignee).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "alice"); // the PR itself
expect(ensurePullRequestAssignee).toHaveBeenCalledWith(env, 123, "owner/repo", 42, "alice"); // linked issue #42
expect(ensurePullRequestAssignee).toHaveBeenCalledWith(env, 123, "owner/repo", 43, "alice"); // linked issue #43
expect(ensurePullRequestAssignee).toHaveBeenCalledTimes(3);
expect(outcomes[0]?.outcome).toBe("completed");
});

it("LIVE assign: a linked issue's assignment failing does not affect the PR-assign outcome or the other linked issue", async () => {
const env = createTestEnv({});
vi.mocked(ensurePullRequestAssignee).mockImplementation(async (_env, _installationId, _repoFullName, issueOrPrNumber) => {
if (issueOrPrNumber === 42) throw new Error("boom");
return { applied: true };
});
const assign: PlannedAgentAction = { actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener", assignee: "alice", assignLinkedIssues: [42, 43] };
const outcomes = await executeAgentMaintenanceActions(env, ctx({ autonomy: { assign: "auto" } }), [assign]);
expect(outcomes[0]?.outcome).toBe("completed");
expect(ensurePullRequestAssignee).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "alice");
expect(ensurePullRequestAssignee).toHaveBeenCalledWith(env, 123, "owner/repo", 43, "alice");
});

it("LIVE assign: no linkedIssues means no extra assignee calls beyond the PR itself", async () => {
const env = createTestEnv({});
const assign: PlannedAgentAction = { actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener", assignee: "alice" };
await executeAgentMaintenanceActions(env, ctx({ autonomy: { assign: "auto" } }), [assign]);
expect(ensurePullRequestAssignee).toHaveBeenCalledTimes(1);
});

it("assign with no login is a no-op (defensive — the planner always sets it, but the executor must not call GitHub with an empty login)", async () => {
const env = createTestEnv({});
const assign: PlannedAgentAction = { actionClass: "assign", requiresApproval: false, reason: "auto-assign PR opener" };
Expand Down
26 changes: 26 additions & 0 deletions test/unit/agent-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,32 @@ describe("assign — auto-assign PR opener (#3182)", () => {
expect(classes(plan)).not.toContain("assign");
expect(classes(plan)).toContain("close");
});

describe("#priority-linked-issue-gate-ownership: also assigns the PR's linked issues", () => {
it("threads linkedIssues into assignLinkedIssues when present", () => {
const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { assign: "auto" }, pr: { labels: [], authorLogin: "alice", linkedIssues: [42, 43] } }));
expect(plan).toContainEqual(expect.objectContaining({ actionClass: "assign", assignee: "alice", assignLinkedIssues: [42, 43] }));
});

it("omits assignLinkedIssues when linkedIssues is absent", () => {
const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { assign: "auto" }, pr: { labels: [], authorLogin: "alice" } }));
const assign = plan.find((a) => a.actionClass === "assign");
expect(assign).not.toHaveProperty("assignLinkedIssues");
});

it("omits assignLinkedIssues when linkedIssues is an empty array", () => {
const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { assign: "auto" }, pr: { labels: [], authorLogin: "alice", linkedIssues: [] } }));
const assign = plan.find((a) => a.actionClass === "assign");
expect(assign).not.toHaveProperty("assignLinkedIssues");
});

it("caps assignLinkedIssues at ASSIGN_LINKED_ISSUES_MAX (10) — a PR cannot fan out unbounded assignee writes", () => {
const linkedIssues = Array.from({ length: 15 }, (_, i) => i + 1);
const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { assign: "auto" }, pr: { labels: [], authorLogin: "alice", linkedIssues } }));
const assign = plan.find((a) => a.actionClass === "assign");
expect(assign?.assignLinkedIssues).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
});
});

describe("isProtectedAutomationAuthor", () => {
Expand Down