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: 20 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8090,6 +8090,26 @@
]
}
}
},
"autoMaintain": {
"type": "object",
"properties": {
"requireApprovals": {
"type": "integer"
},
"mergeMethod": {
"type": "string",
"enum": [
"merge",
"squash",
"rebase"
]
}
},
"required": [
"requireApprovals",
"mergeMethod"
]
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,30 @@ type MaintainerSettings = {
requireLinkedIssue: boolean;
badgeEnabled: boolean;
commandAuthorization: CommandAuthorization;
autonomy: Partial<Record<AgentActionClass, AutonomyLevel>>;
autoMaintain: { requireApprovals: number; mergeMethod: AutoMergeMethod };
};

type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto";
type AgentActionClass = "review" | "request_changes" | "approve" | "merge" | "close" | "label";
type AutoMergeMethod = "merge" | "squash" | "rebase";

const AUTONOMY_LEVELS: AutonomyLevel[] = [
"observe",
"suggest",
"propose",
"auto_with_approval",
"auto",
];
const AGENT_ACTION_CLASSES: AgentActionClass[] = [
"review",
"request_changes",
"approve",
"merge",
"close",
"label",
];

type Message = { kind: "ok" | "err"; text: string };

const GATE_MODE_OPTIONS: Array<[GateMode, string]> = [
Expand Down Expand Up @@ -84,6 +106,8 @@ const EDITABLE_KEYS: Array<keyof MaintainerSettings> = [
"requireLinkedIssue",
"badgeEnabled",
"commandAuthorization",
"autonomy",
"autoMaintain",
];

type SelectFieldDef = {
Expand Down Expand Up @@ -277,7 +301,19 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
credentials: "include",
silentStatus: true,
});
setSettings(result.ok ? result.data : null);
// Default the agent-layer fields defensively so the editor renders even against an older response shape.
setSettings(
result.ok
? {
...result.data,
autonomy: result.data.autonomy ?? {},
autoMaintain: result.data.autoMaintain ?? {
requireApprovals: 1,
mergeMethod: "squash",
},
}
: null,
);
setLoading(false);
}, [repoFullName]);

Expand Down Expand Up @@ -443,6 +479,76 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
) : null}
</div>

<div>
<h3 className={LABEL_CLASS}>Auto-maintain (agent layer)</h3>
<p className="mt-1 text-token-2xs text-muted-foreground">
Per-action autonomy: <code className="font-mono">observe</code> (watch only) →{" "}
<code className="font-mono">suggest</code> →{" "}
<code className="font-mono">propose</code> →{" "}
<code className="font-mono">auto_with_approval</code> →{" "}
<code className="font-mono">auto</code>. Deny-by-default — anything left at{" "}
<code className="font-mono">observe</code> never acts.
</p>
<div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{AGENT_ACTION_CLASSES.map((actionClass) => (
<label key={actionClass} className="block">
<span className={LABEL_CLASS}>{actionClass.replace(/_/g, " ")}</span>
<select
value={settings.autonomy[actionClass] ?? "observe"}
onChange={(event) =>
setField("autonomy", {
...settings.autonomy,
[actionClass]: event.target.value as AutonomyLevel,
})
}
className={FIELD_CLASS}
>
{AUTONOMY_LEVELS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</label>
))}
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block">
<span className={LABEL_CLASS}>Approvals before auto-merge</span>
<input
type="number"
min={0}
max={10}
value={settings.autoMaintain.requireApprovals}
onChange={(event) =>
setField("autoMaintain", {
...settings.autoMaintain,
requireApprovals: Math.max(0, Math.min(10, Number(event.target.value) || 0)),
})
}
className={FIELD_CLASS}
/>
</label>
<label className="block">
<span className={LABEL_CLASS}>Merge method</span>
<select
value={settings.autoMaintain.mergeMethod}
onChange={(event) =>
setField("autoMaintain", {
...settings.autoMaintain,
mergeMethod: event.target.value as AutoMergeMethod,
})
}
className={FIELD_CLASS}
>
<option value="merge">merge</option>
<option value="squash">squash</option>
<option value="rebase">rebase</option>
</select>
</label>
</div>
</div>

<div className="flex flex-wrap items-center gap-3">
<button
type="button"
Expand Down
4 changes: 4 additions & 0 deletions migrations/0043_agent_auto_maintain.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Auto-maintain policy (#774, Wave 2 Phase 0). Per-repo merge method + approval count for the agent action
-- layer, stored as JSON ({ requireApprovals, mergeMethod }). Default '{}' resolves to the conservative
-- defaults (squash / 1 approval) via normalizeAutoMaintainPolicy. Additive; existing repos are unaffected.
ALTER TABLE repository_settings ADD COLUMN auto_maintain_json TEXT NOT NULL DEFAULT '{}';
4 changes: 4 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@ const maintainerSettingsSchema = z
default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(),
commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(),
}),
// Agent-layer config (#773/#774). The DB layer normalizes both (autonomy: deny-by-default; autoMaintain:
// defaults filled), so a loose record/object here is safe — invalid entries are dropped on persist.
autonomy: z.record(z.string().trim().min(1).max(32), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"])),
autoMaintain: z.object({ requireApprovals: z.number().int().min(0).max(10).optional(), mergeMethod: z.enum(["merge", "squash", "rebase"]).optional() }),
})
.partial();

Expand Down
12 changes: 11 additions & 1 deletion src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import type {
AgentActionStatus,
AgentActionType,
AutonomyPolicy,
AutoMaintainPolicy,
AgentCommandAnswerRecord,
AgentCommandFeedbackRecord,
AgentContextSnapshotRecord,
Expand Down Expand Up @@ -151,7 +152,7 @@ import type {
import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api";
import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeAutonomyPolicy } from "../settings/autonomy";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { jsonString, nowIso, parseJson, repoParts } from "../utils/json";

Expand Down Expand Up @@ -425,6 +426,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
badgeEnabled: false,
commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy,
autonomy: {},
autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY },
};
}
return {
Expand Down Expand Up @@ -461,6 +463,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
badgeEnabled: row.badgeEnabled,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -501,6 +504,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
badgeEnabled: settings.badgeEnabled ?? false,
commandAuthorization: normalizeCommandAuthorizationPolicy(settings.commandAuthorization).policy,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
autoMaintain: normalizeAutoMaintainPolicy(settings.autoMaintain),
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -539,6 +543,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
badgeEnabled: resolved.badgeEnabled,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -578,6 +583,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
badgeEnabled: resolved.badgeEnabled,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -4972,6 +4978,10 @@ function parseAutonomyPolicy(value: string): AutonomyPolicy {
return normalizeAutonomyPolicy(parseJson<unknown>(value, null));
}

function parseAutoMaintainPolicy(value: string): AutoMaintainPolicy {
return normalizeAutoMaintainPolicy(parseJson<unknown>(value, null));
}

function parseSyncStatus(value: string): RepoSyncStateRecord["status"] {
if (
value === "running" ||
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false),
commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"),
autonomyJson: text("autonomy_json").notNull().default("{}"),
autoMaintainJson: text("auto_maintain_json").notNull().default("{}"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ export const RepositorySettingsSchema = z
autonomy: z
.record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"]))
.optional(),
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
29 changes: 28 additions & 1 deletion src/settings/autonomy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AgentActionClass, AutonomyLevel, AutonomyPolicy } from "../types";
import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyLevel, AutonomyPolicy } from "../types";

// The graduated autonomy dial (#773), ordered least → most autonomous. Every later agent-layer phase reads
// this BEFORE acting. `observe` is the deny-by-default floor — gittensory watches but never takes an action.
Expand Down Expand Up @@ -48,3 +48,30 @@ export function normalizeAutonomyPolicy(input: unknown): AutonomyPolicy {
}
return policy;
}

// Auto-maintain policy (#774): how an action behaves once its autonomy level permits acting.
export const AUTO_MERGE_METHODS = ["merge", "squash", "rebase"] as const;
const AUTO_MERGE_METHOD_SET = new Set<string>(AUTO_MERGE_METHODS);

// Conservative defaults: squash (the tidiest history) + a single human approval before any auto-merge.
export const DEFAULT_AUTO_MAINTAIN_POLICY: AutoMaintainPolicy = { requireApprovals: 1, mergeMethod: "squash" };

// Approvals are clamped to a sane band so a malformed config can't disable the gate (negative) or stall it.
const MAX_REQUIRE_APPROVALS = 10;

/**
* Parse/validate an arbitrary value into an AutoMaintainPolicy, filling the conservative defaults for any
* missing/invalid field. `requireApprovals` is clamped to [0, 10]. Pure.
*/
export function normalizeAutoMaintainPolicy(input: unknown): AutoMaintainPolicy {
if (typeof input !== "object" || input === null || Array.isArray(input)) return { ...DEFAULT_AUTO_MAINTAIN_POLICY };
const record = input as Record<string, unknown>;
const rawApprovals = record.requireApprovals;
const requireApprovals =
typeof rawApprovals === "number" && Number.isFinite(rawApprovals)
? Math.min(MAX_REQUIRE_APPROVALS, Math.max(0, Math.trunc(rawApprovals)))
: DEFAULT_AUTO_MAINTAIN_POLICY.requireApprovals;
const rawMethod = record.mergeMethod;
const mergeMethod = typeof rawMethod === "string" && AUTO_MERGE_METHOD_SET.has(rawMethod) ? (rawMethod as AutoMergeMethod) : DEFAULT_AUTO_MAINTAIN_POLICY.mergeMethod;
return { requireApprovals, mergeMethod };
}
8 changes: 7 additions & 1 deletion src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parse as parseYaml } from "yaml";
import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types";
import { normalizeAutonomyPolicy } from "../settings/autonomy";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy";

export type FocusManifestSource = "repo_file" | "api_record" | "none";
export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional";
Expand Down Expand Up @@ -68,6 +68,7 @@ export type FocusManifestSettings = Partial<
| "backfillEnabled"
| "privateTrustEnabled"
| "autonomy"
| "autoMaintain"
>
>;

Expand Down Expand Up @@ -434,6 +435,11 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
const autonomy = normalizeAutonomyPolicy(r.autonomy);
if (Object.keys(autonomy).length > 0) out.autonomy = autonomy;
}
// Auto-maintain policy (#774): `settings.autoMaintain` declares the full policy (defaults fill any unset
// field) and overlays the DB value via the resolver. Only a mapping is honoured; anything else is ignored.
if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) {
out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain);
}
return out;
}

Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,9 @@ export type RepositorySettings = {
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */
autonomy?: AutonomyPolicy | undefined;
/** Auto-maintain policy (#774): merge method + approval count. Always populated by the DB layer with
* defaults (squash / 1 approval); optional so existing settings fixtures/callers need not be touched. */
autoMaintain?: AutoMaintainPolicy | undefined;
createdAt?: string | null | undefined;
updatedAt?: string | null | undefined;
};
Expand All @@ -484,6 +487,17 @@ export type AgentActionClass = "review" | "request_changes" | "approve" | "merge
/** Per-action-class autonomy. An unset class resolves to `observe` (deny-by-default). */
export type AutonomyPolicy = Partial<Record<AgentActionClass, AutonomyLevel>>;

/** How the agent merges when it auto-merges (#774). */
export type AutoMergeMethod = "merge" | "squash" | "rebase";

/** Auto-maintain policy (#774): the "how" once an action is at an acting autonomy level. `requireApprovals`
* is the human approval count an `auto_with_approval` action waits for (#779); `mergeMethod` is how an
* auto-merge merges. Always populated by the DB layer with defaults. */
export type AutoMaintainPolicy = {
requireApprovals: number;
mergeMethod: AutoMergeMethod;
};

export type RepoSyncStateRecord = {
repoFullName: string;
status: "never_synced" | "running" | "success" | "partial" | "error" | "skipped" | "capped" | "rate_limited" | "stale";
Expand Down
22 changes: 20 additions & 2 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2212,11 +2212,29 @@ describe("api routes", () => {
// override; unrelated groups are preserved by the load-merge in the handler.
const settingsUpdate = await app.request(
"/v1/repos/repo-owner/owned-repo/settings",
{ method: "PUT", headers: ownerHeaders, body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55 }) },
{
method: "PUT",
headers: ownerHeaders,
// #773/#774: the agent-layer config is settable here; the DB layer drops an unknown action class.
body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" } }),
},
ownerEnv,
);
expect(settingsUpdate.status).toBe(200);
await expect(settingsUpdate.json()).resolves.toMatchObject({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55 });
await expect(settingsUpdate.json()).resolves.toMatchObject({
gateCheckMode: "enabled",
slopGateMode: "block",
slopGateMinScore: 55,
autonomy: { merge: "auto_with_approval" }, // unknown action class dropped by the DB normalizer
autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" },
});
// requireApprovals is bounded at the API boundary — an out-of-range value is rejected, not silently clamped.
const settingsBadApprovals = await app.request(
"/v1/repos/repo-owner/owned-repo/settings",
{ method: "PUT", headers: ownerHeaders, body: JSON.stringify({ autoMaintain: { requireApprovals: 99 } }) },
ownerEnv,
);
expect(settingsBadApprovals.status).toBe(400);
const settingsInvalid = await app.request(
"/v1/repos/repo-owner/owned-repo/settings",
{ method: "PUT", headers: ownerHeaders, body: JSON.stringify({ gateCheckMode: "nonsense" }) },
Expand Down
Loading
Loading