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
74 changes: 69 additions & 5 deletions .github/scripts/closed-pr-branch-cleanup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ function normalizeBranchName(value) {
return String(value || "").trim();
}

/**
* A commit id, lowercased for comparison.
*
* The REST and GraphQL APIs are not consistent about case, and a full 40-character
* sha compared case-sensitively against an abbreviated or upper-case one silently
* reads as "different" - which here would mean "keep", so the failure direction is
* safe, but it would make the guard useless rather than protective. Anything that
* is not a plausible hex object id becomes null, i.e. unknown.
*/
function normalizeOid(value) {
const text = String(value || "").trim().toLowerCase();
return /^[0-9a-f]{7,64}$/.test(text) ? text : null;
}

function isProtectedBranch(name) {
return PROTECTED_BRANCHES.includes(normalizeBranchName(name));
}
Expand All @@ -45,6 +59,8 @@ const KEEP_REASONS = Object.freeze({
CROSS_REPOSITORY: "cross-repository-head",
MISSING_CLOSED_AT: "missing-closed-at",
WITHIN_GRACE: "within-grace-period",
MOVED_SINCE_CLOSE: "branch-moved-since-close",
UNKNOWN_HEAD_SHA: "unknown-head-sha",
});

/**
Expand All @@ -63,12 +79,23 @@ const KEEP_REASONS = Object.freeze({
* contributor's repository and this token has no business there.
* - A grace period after `closed_at` leaves room to reopen a PR that was
* closed by mistake.
* - The branch must still POINT AT a commit one of those closed pull requests
* had as its head. Matching by NAME alone deletes reused work: `codex/`-style
* names get picked up again all the time, and a branch recreated for new work
* inherits the closed history of every PR that ever used that name. The tip
* moved, so the branch is not the closed PR's branch any more - it only shares
* its label.
* - A branch whose current tip cannot be determined is kept. An unknown tip is
* not evidence of an abandoned branch, and this job's mistakes are not
* recoverable.
*
* @param {object} input
* @param {Array<object>} input.pullRequests Pull requests with
* `headRefName`, `baseRefName`, `state`, `merged`, `closedAt`, and
* `isCrossRepository`.
* @param {Array<string>} input.branches Branch names that currently exist.
* `headRefName`, `headRefOid`, `baseRefName`, `state`, `merged`, `closedAt`,
* and `isCrossRepository`.
* @param {Array<string|{name: string, oid?: string}>} input.branches Branches
* that currently exist. A bare string carries no tip, which is treated as an
* unknown tip and kept.
* @param {number} [input.now] Current time in milliseconds.
* @param {number} [input.graceDays] Days to wait after `closedAt`.
* @returns {{ deletions: Array<{branch: string, pullRequests: number[]}>,
Expand All @@ -80,7 +107,17 @@ function planClosedPrBranchDeletions({
now = Date.now(),
graceDays = DEFAULT_GRACE_DAYS,
}) {
const existing = new Set(branches.map(normalizeBranchName).filter(Boolean));
// Accepts both shapes so an older caller passing bare names still works - it
// just gets the conservative answer, because a name without a tip cannot be
// proven safe to delete.
/** @type {Map<string, string|null>} */
const existing = new Map();
for (const entry of branches) {
const name = normalizeBranchName(typeof entry === "string" ? entry : entry && entry.name);
if (!name) continue;
const oid = typeof entry === "string" ? null : normalizeOid(entry && entry.oid);
existing.set(name, oid);
}
const graceMs = Math.max(0, Number(graceDays) || 0) * 24 * 60 * 60 * 1000;

/** @type {Map<string, object[]>} */
Expand All @@ -104,7 +141,7 @@ function planClosedPrBranchDeletions({
const deletions = [];
const keeps = [];

for (const branch of [...existing].sort()) {
for (const branch of [...existing.keys()].sort()) {
if (isProtectedBranch(branch)) {
keeps.push({ branch, reason: KEEP_REASONS.PROTECTED });
continue;
Expand Down Expand Up @@ -141,6 +178,33 @@ function planClosedPrBranchDeletions({
continue;
}

// The tip check, last because it is the most expensive claim to satisfy and
// the cheaper rules above have already excluded most branches.
//
// A closed PR's head branch is only THIS branch if the branch still points at
// a commit that PR had as its head. Without this, a name reused for new work
// is deleted on the strength of an unrelated PR that happened to share the
// label months earlier - and a deleted branch whose commits were never pushed
// anywhere else is gone.
const currentOid = existing.get(branch) || null;
if (!currentOid) {
keeps.push({ branch, reason: KEEP_REASONS.UNKNOWN_HEAD_SHA });
continue;
}
const closedOids = new Set(
related.map((pr) => normalizeOid(pr && pr.headRefOid)).filter(Boolean),
);
// An empty set means the API gave us no head SHA for any of them, which is the
// unknown case again rather than a licence to delete.
if (closedOids.size === 0) {
keeps.push({ branch, reason: KEEP_REASONS.UNKNOWN_HEAD_SHA });
continue;
}
if (!closedOids.has(currentOid)) {
keeps.push({ branch, reason: KEEP_REASONS.MOVED_SINCE_CLOSE });
continue;
}

deletions.push({
branch,
pullRequests: related
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/cleanup-closed-pr-branches.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ jobs:
merged: Boolean(pr.merged_at),
closedAt: pr.closed_at,
headRefName: pr.head && pr.head.ref,
// The tip this PR actually pointed at. Without it the planner cannot
// tell a genuinely abandoned branch from a name someone reused, and
// keeps the branch instead of deleting it.
headRefOid: pr.head && pr.head.sha,
baseRefName: pr.base && pr.base.ref,
// A fork head lives in the contributor's repository. Comparing
// repo ids (not names) keeps a same-name fork from looking local.
Expand All @@ -82,7 +86,10 @@ jobs:
repo,
per_page: 100,
});
const branches = rawBranches.map((branch) => branch.name);
const branches = rawBranches.map((branch) => ({
name: branch.name,
oid: branch.commit && branch.commit.sha,
}));
const protectedByGitHub = new Set(
rawBranches.filter((branch) => branch.protected).map((branch) => branch.name),
);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 17 additions & 2 deletions gui/src/components/codex-set/PromptLayerRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useT } from "../../i18n/shared";
import { navigateHash } from "../../hash-routing";
import type { LayerDescriptorDto, ToggleStateDto } from "../../pages/codex-set-prompt";
import { LAYER_LABEL_KEYS } from "./prompt-layer-copy";
import { LAYER_CONDITION_KEYS, LAYER_LABEL_KEYS } from "./prompt-layer-copy";

/**
* One row of the prompt-layer list.
Expand Down Expand Up @@ -47,6 +47,10 @@ export default function PromptLayerRow({
// An id this build has no copy for is shown verbatim rather than blank: a newer
// runtime listing a layer we do not know about is information, not an error.
const label = labelKey ? t(labelKey) : descriptor.id;
// A layer that only appears under a condition has one; the rest do not (the map is
// deliberately partial). Read here as well as in the dialog so a conditional row
// never claims to be unconditional.
const conditionKey = LAYER_CONDITION_KEYS[descriptor.id];
const checked = toggle?.defaultedUserValue ?? descriptor.default ?? true;

return (
Expand Down Expand Up @@ -123,8 +127,19 @@ export default function PromptLayerRow({
"Always on" is false for a transition notice: it is not on, it fires.
Reusing the locked label would tell the user this text is in every
prompt when it appears only at a change.

It is equally false for a layer with a CONDITION. `plugins` is emitted
when a plugin is selected or advertises a capability, and
`git-attribution` follows the account's attribution policy - neither is
unconditionally present, and the dialog has always said so while the row
said "Always on". The row now prefers the condition when one exists, so
the two surfaces cannot disagree about the same layer.
*/}
{transitionOnly ? t("codexSet.row.onChange") : t("codexSet.row.alwaysOn")}
{transitionOnly
? t("codexSet.row.onChange")
: conditionKey
? t(conditionKey)
: t("codexSet.row.alwaysOn")}
</span>
)}
</li>
Expand Down
9 changes: 8 additions & 1 deletion gui/src/components/codex-set/prompt-layer-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export type LayerId =
| "plugins"
| "tools"
| "skills"
| "multi-agent-mode";
| "multi-agent-mode"
| "git-attribution";

/**
* Layer id -> i18n key, written out rather than built by string concatenation.
Expand All @@ -49,6 +50,7 @@ export const LAYER_LABEL_KEYS: Record<LayerId, TKey> = {
tools: "codexSet.layer.tools",
skills: "codexSet.layer.skills",
"multi-agent-mode": "codexSet.layer.multi-agent-mode",
"git-attribution": "codexSet.layer.git-attribution",
};

export const LAYER_ABOUT_KEYS: Record<LayerId, TKey> = {
Expand All @@ -67,6 +69,7 @@ export const LAYER_ABOUT_KEYS: Record<LayerId, TKey> = {
tools: "codexSet.about.tools",
skills: "codexSet.about.skills",
"multi-agent-mode": "codexSet.about.multi-agent-mode",
"git-attribution": "codexSet.about.git-attribution",
};

/**
Expand All @@ -80,6 +83,10 @@ export const LAYER_CONDITION_KEYS: Partial<Record<LayerId, TKey>> = {
realtime: "codexSet.condition.realtime",
"agents-md": "codexSet.condition.agents-md",
plugins: "codexSet.condition.plugins",
// Mandatory for this row, not optional: without it the renderer falls through to
// "always on", and that is false - the account can turn attribution off, in which
// case Codex sends the opposite instruction rather than sending nothing.
"git-attribution": "codexSet.condition.git-attribution",
};

export const CLASS_LABEL_KEYS: Record<LayerClass, TKey> = {
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,7 @@ export const de: Record<TKey, string> = {
"codexSet.layer.plugins": "Plugins",
"codexSet.layer.tools": "Tools",
"codexSet.layer.multi-agent-mode": "Multi-Agenten-Modus",
"codexSet.layer.git-attribution": "Commit-Attribution",
"codexSet.about.base-instructions": "Codex-eigene Anweisungen. Sie werden mit der Anfrage selbst gesendet und können nicht deaktiviert werden.",
"codexSet.about.model-switch": "Wird hinzugefügt, wenn das Modell während einer Unterhaltung gewechselt wird.",
"codexSet.about.personality": "Vorgaben für Ton und Ausdruck, gesteuert durch ein Feature-Flag.",
Expand All @@ -974,10 +975,12 @@ export const de: Record<TKey, string> = {
"codexSet.about.tools": "Verzögert geladene Tool-Beschreibungen, gesteuert durch ein Feature-Flag.",
"codexSet.about.skills": "Liste der verfügbaren Skills.",
"codexSet.about.multi-agent-mode": "Anweisungen für Subagenten, gesteuert durch ein Feature-Flag.",
"codexSet.about.git-attribution": "Lässt das Modell einen Co-authored-by: Codex-Trailer in Commits schreiben, die es erstellt, und eine Zeile Generated with Codex. in Pull Requests, die es öffnet. Codex liest das aus deinem Konto, deshalb ist es weder hier noch unter [features] einstellbar. Ist es im Konto aus, sendet Codex die umgekehrte Anweisung statt gar keine.",
"codexSet.condition.model-switch": "Wird nur nach einem Modellwechsel während der Sitzung ausgegeben.",
"codexSet.condition.realtime": "Wird nur in einer Echtzeitsitzung ausgegeben.",
"codexSet.condition.agents-md": "Wird ausgegeben, wenn für das Arbeitsverzeichnis eine Projektdokumentation gefunden wird.",
"codexSet.condition.plugins": "Wird ausgegeben, wenn ein Plugin ausgewählt ist oder ein Plugin eine Funktion bereitstellt.",
"codexSet.condition.git-attribution": "Wird durch die Attributionsrichtlinie deines Kontos bestimmt.",
"nav.api": "API",
"nav.integrations": "Integrationen",
"nav.openMenu": "Menü öffnen",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,7 @@ export const en = {
"codexSet.layer.plugins": "Plugins",
"codexSet.layer.tools": "Tools",
"codexSet.layer.multi-agent-mode": "Multi-agent mode",
"codexSet.layer.git-attribution": "Commit attribution",
"codexSet.about.base-instructions": "Codex's own instructions. They travel with the request itself and cannot be turned off.",
"codexSet.about.model-switch": "Added when the session changes model mid-conversation.",
"codexSet.about.personality": "Tone and voice guidance, governed by a feature flag.",
Expand All @@ -1468,10 +1469,12 @@ export const en = {
"codexSet.about.tools": "Deferred tool descriptions, governed by a feature flag.",
"codexSet.about.skills": "The list of available skills.",
"codexSet.about.multi-agent-mode": "Subagent instructions, governed by a feature flag.",
"codexSet.about.git-attribution": "Tells the model to add a Co-authored-by: Codex trailer to commits it writes, and a Generated with Codex. line to pull requests it opens. Codex resolves this from your account, so there is no setting for it here or under [features]. When your account has it off, Codex sends the opposite instruction rather than sending nothing.",
"codexSet.condition.model-switch": "Emitted only after a mid-session model change.",
"codexSet.condition.realtime": "Emitted only in a realtime session.",
"codexSet.condition.agents-md": "Emitted when a project doc is found for the working directory.",
"codexSet.condition.plugins": "Emitted when a plugin is selected or any plugin advertises a capability.",
"codexSet.condition.git-attribution": "Set by your account's attribution policy.",
"nav.api": "API",
"nav.integrations": "Integrations",
"nav.openMenu": "Open menu",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,7 @@ export const fr: Record<TKey, string> = {
"codexSet.layer.plugins": "Plugins",
"codexSet.layer.tools": "Outils",
"codexSet.layer.multi-agent-mode": "Mode multi-agents",
"codexSet.layer.git-attribution": "Attribution des commits",
"codexSet.about.base-instructions": "Instructions propres à Codex. Elles accompagnent la requête elle-même et ne peuvent pas être désactivées.",
"codexSet.about.model-switch": "Ajouté lorsque le modèle change en cours de conversation.",
"codexSet.about.personality": "Consignes de ton et de style, régies par un indicateur de fonctionnalité.",
Expand All @@ -1441,10 +1442,12 @@ export const fr: Record<TKey, string> = {
"codexSet.about.tools": "Descriptions différées des outils, régies par un indicateur de fonctionnalité.",
"codexSet.about.skills": "Liste des compétences disponibles.",
"codexSet.about.multi-agent-mode": "Instructions pour les sous-agents, régies par un indicateur de fonctionnalité.",
"codexSet.about.git-attribution": "Demande au modèle d’ajouter un trailer Co-authored-by: Codex aux commits qu’il écrit, et une ligne Generated with Codex. aux pull requests qu’il ouvre. Codex lit ce réglage depuis votre compte : il n’est modifiable ni ici ni dans [features]. Si votre compte le désactive, Codex envoie l’instruction inverse au lieu de ne rien envoyer.",
"codexSet.condition.model-switch": "Émis uniquement après un changement de modèle en cours de session.",
"codexSet.condition.realtime": "Émis uniquement dans une session en temps réel.",
"codexSet.condition.agents-md": "Émis lorsqu’un document de projet est trouvé pour le répertoire de travail.",
"codexSet.condition.plugins": "Émis lorsqu’un plugin est sélectionné ou qu’un plugin déclare une capacité.",
"codexSet.condition.git-attribution": "Défini par la politique d’attribution de votre compte.",
"nav.api": "API",
"nav.integrations": "Intégrations",
"nav.openMenu": "Ouvrir le menu",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,7 @@ export const ja: Record<TKey, string> = {
"codexSet.layer.plugins": "プラグイン",
"codexSet.layer.tools": "ツール",
"codexSet.layer.multi-agent-mode": "マルチエージェントモード",
"codexSet.layer.git-attribution": "コミットの帰属表示",
"codexSet.about.base-instructions": "Codex 自体の指示です。リクエストに含まれ、無効にはできません。",
"codexSet.about.model-switch": "会話の途中でセッションのモデルが変わると追加されます。",
"codexSet.about.personality": "機能フラグで制御されるトーンと語調のガイダンスです。",
Expand All @@ -1401,10 +1402,12 @@ export const ja: Record<TKey, string> = {
"codexSet.about.tools": "機能フラグで制御される遅延読み込みツールの説明です。",
"codexSet.about.skills": "利用可能なスキルの一覧です。",
"codexSet.about.multi-agent-mode": "機能フラグで制御されるサブエージェント向けの指示です。",
"codexSet.about.git-attribution": "モデルが書いたコミットに Co-authored-by: Codex トレーラーを、開いたプルリクエストに Generated with Codex. の行を追加させます。Codex がアカウントから取得するため、ここでも [features] でも変更できません。アカウント側で無効にすると、何も送らないのではなく逆の指示を送ります。",
"codexSet.condition.model-switch": "セッション中にモデルが変わった後のみ含まれます。",
"codexSet.condition.realtime": "リアルタイムセッションのみ含まれます。",
"codexSet.condition.agents-md": "作業ディレクトリ用のプロジェクト文書が見つかると含まれます。",
"codexSet.condition.plugins": "プラグインが選択されているか、プラグインが機能を提供すると含まれます。",
"codexSet.condition.git-attribution": "アカウントの帰属表示ポリシーで決まります。",
"nav.api": "API",
"nav.integrations": "連携",
"nav.openMenu": "メニューを開く",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,7 @@ export const ko: Record<TKey, string> = {
"codexSet.layer.plugins": "플러그인",
"codexSet.layer.tools": "도구",
"codexSet.layer.multi-agent-mode": "멀티 에이전트 모드",
"codexSet.layer.git-attribution": "커밋 어트리뷰션",
"codexSet.about.base-instructions": "Codex 자체 지침입니다. 요청에 포함되며 끌 수 없습니다.",
"codexSet.about.model-switch": "대화 도중 세션 모델이 바뀌면 추가됩니다.",
"codexSet.about.personality": "기능 플래그로 제어되는 어조와 말투 지침입니다.",
Expand All @@ -1000,10 +1001,12 @@ export const ko: Record<TKey, string> = {
"codexSet.about.tools": "기능 플래그로 제어되는 지연 로드 도구 설명입니다.",
"codexSet.about.skills": "사용 가능한 스킬 목록입니다.",
"codexSet.about.multi-agent-mode": "기능 플래그로 제어되는 서브에이전트 지침입니다.",
"codexSet.about.git-attribution": "모델이 작성한 커밋에 Co-authored-by: Codex 트레일러를, 새로 여는 풀 리퀘스트에 Generated with Codex. 한 줄을 붙이게 합니다. Codex가 계정에서 이 값을 가져오기 때문에 여기서도 [features]에서도 바꿀 수 없습니다. 계정에서 꺼두면 아무것도 보내지 않는 대신 반대 지시를 보냅니다.",
"codexSet.condition.model-switch": "세션 도중 모델이 바뀐 뒤에만 포함됩니다.",
"codexSet.condition.realtime": "실시간 세션에만 포함됩니다.",
"codexSet.condition.agents-md": "작업 디렉터리에서 프로젝트 문서를 찾으면 포함됩니다.",
"codexSet.condition.plugins": "플러그인을 선택했거나 플러그인이 기능을 제공하면 포함됩니다.",
"codexSet.condition.git-attribution": "계정의 어트리뷰션 정책이 결정합니다.",
"nav.openMenu": "메뉴 열기",
"nav.closeMenu": "메뉴 닫기",
"integrations.subtitle": "클라이언트를 opencodex에 연결하고 자격 증명과 설정 복원을 관리합니다.",
Expand Down
Loading
Loading