Codex use case
Issue からテスト済みの pull request まで
受け入れ基準を示して小規模な機能の実装を依頼し、検証結果を確認します。
Codex に対応範囲が明確な Issue と準備済みのクラウド環境を用意し、実行するチェックを指定します。実装、テスト、ドキュメントがそろい、受け入れ基準ごとの検証結果を確認しながら変更をレビューできます。
Best for
- 対応範囲が明確な API の Issue に取り組むスタートアップのエンジニア
- 実装とその検証結果をあわせて確認したいレビュアー
- 受け入れ基準が定まり、テストを実行できる環境が整っているチーム
Contents
Issue からテスト済みの pull request まで
受け入れ基準を示して小規模な機能の実装を依頼し、検証結果を確認します。
Codex に対応範囲が明確な Issue と準備済みのクラウド環境を用意し、実行するチェックを指定します。実装、テスト、ドキュメントがそろい、受け入れ基準ごとの検証結果を確認しながら変更をレビューできます。
Codex に対応範囲が明確な Issue と準備済みのクラウド環境を用意し、実行するチェックを指定します。実装、テスト、ドキュメントがそろい、受け入れ基準ごとの検証結果を確認しながら変更をレビューできます。
Related links
Best for
- 対応範囲が明確な API の Issue に取り組むスタートアップのエンジニア
- 実装とその検証結果をあわせて確認したいレビュアー
- 受け入れ基準が定まり、テストを実行できる環境が整っているチーム
Starter prompt
範囲が狭く要件が明確な issue を Codex に渡し、チームメンバーがレビューできる形にまとめるよう依頼します。実装、テスト、ドキュメントに加えて、要求どおりに動作したことを示す検証記録も提出してもらいます。実装と検証でリポジトリの設定を揃えるため、タスクは事前に準備した 1 つのクラウド環境で進めます。
-
Web アプリまたはデスクトップアプリで 作業場所 > クラウド を選び、公開済みの環境を選択します。
-
環境を新しく作るには、環境を作成を選び、対象の GitHub リポジトリを選択します。ランタイム、依存関係、隔離されたテスト用サービスの準備を Codex に依頼します。
-
セットアップレポートを確認し、失敗したチェックの原因を解消します。新しいタスクを開始する前に公開を選択します。
- 最新版のクラウド環境ガイドに従ってください。
- リポジトリ固有の指示は AGENTS.md に記載します。
依存関係やサービスの設定が変わったら、再利用する環境を編集してテストし、再公開を選択します。その後、新しいタスクで変更を確認します。
リポジトリを更新しても、インストールコマンドや起動コマンドは再実行されません。既存のタスクはそれぞれの状態を保持します。
必要なネットワーク接続先とテスト用の認証情報は個別に設定します。接続先を許可するだけでは、サービスへのアクセス権は付与されません。
必須のチェックを実行できない場合は、セットアップを完了してからこのワークフローを使用してください。
このワークフローに適した issue
新しい API エンドポイント、検証ルール、既存サービスへの小規模な機能追加など、仕様が明文化され、完了条件が明確な機能を選びます。1 件の pull request に収まり、本番環境にアクセスせずに検証できる issue が適しています。
この例では、プロジェクトのアクティビティフィードにページネーションを追加します。実行可能な Node.js サンプルには、認証、プロジェクトへのアクセス制御、署名付きカーソル、既存のプロジェクト詳細ルートが含まれています。固定のテストデータをメモリに保持するため、データベースや外部サービスなしでページネーションの仕様を検証できます。
サンプルの実行
サンプルをダウンロードして展開し、issue-to-tested-pr フォルダーを開きます。Node 22.6 以降を使用してください。サンプルは標準ライブラリを使用するため、パッケージのインストールや API キーは不要です。
node --version
node api.mjs
node test.mjs --seeded
node test.mjs
node reproduce.mjs ./evidence
node api.mjs はローカル HTTP サーバーを起動し、最初のページをリクエストして、返された公開 ID を出力してからサーバーを終了します。
--seeded を指定したテストは、意図的にゼロ以外の終了コードを返します。タイムスタンプだけを基準に次のページを取得するため、最初のページの末尾と同じタイムスタンプを持つ 2 件のイベントが抜け落ちます。タイムスタンプと ID の組を基準にするよう修正すると、同じテストが成功します。
不具合の再現コマンドは、不具合を仕込んだ状態でテストが失敗し、修正後の状態で成功した場合に限り成功します。
修正後に limit=2 で順に取得すると、3 回のリクエストで次のページが返ります。
[evt_105, evt_104]
[evt_103, evt_102]
[evt_101]
最初の 2 ページには次のページを取得するためのカーソルがあり、最終ページでは next_cursor: null になります。
不具合を仕込んだ状態では、[evt_105, evt_104] の次に [evt_101] が返り、evt_103 と evt_102 が抜け落ちます。
正しく再現できた場合、修正後は 36 件のテストがすべて成功します。不具合を仕込んだ状態では、ページネーションの欠陥を検出する 3 つのアサーションが失敗します。
環境を検証するには、選択したリポジトリにファイルを配置し、サポート対象の Node ランタイムを備えた環境を準備して公開します。その後、新しいクラウドタスクで各コマンドを実行し、結果を記録します。
reproduce.mjs は、red.log、green.log、observed-http.json、run-manifest.json を指定した検証記録ディレクトリに保存します。マニフェストには、ランタイム、コマンド、終了コード、仕込んだ不具合、修正内容、ソースファイルの SHA-256 ハッシュが記録されます。
セッショントークンと署名鍵は記録されません。サンプルのセットアップ手順と公開 API の仕様は README.md と openapi.json を参照してください。
サンプルの実装内容
以下はダウンロードに含まれるファイルの抜粋です。実行には、ZIP を展開して取り出した完全なファイルを使用してください。
カーソルと並べ替えの実装
export function createApi({
sessions,
projects,
events,
signingKey,
seededBug = false,
}) {
return createServer((request, response) => {
const send = (status, body) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
};
const fail = (code) => {
const [status, message] = errors[code];
send(status, { error: { code, message } });
};
// Authenticate, then authorize. Malformed parameters must not change these results.
const authorization = request.headers.authorization ?? "";
const bearer = /^Bearer ([A-Za-z0-9_-]+)$/.exec(authorization);
const tenantId = bearer ? sessions.get(bearer[1]) : undefined;
if (!tenantId) return fail("unauthenticated");
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const route = /^\/v1\/projects\/([^/]+)(\/events)?$/.exec(url.pathname);
let projectId;
try {
projectId = route ? decodeURIComponent(route[1]) : undefined;
} catch {
return fail("not_found");
}
const project = projectId ? projects.get(projectId) : undefined;
if (
request.method !== "GET" ||
!project ||
project.tenant_id !== tenantId
) {
return fail("not_found");
}
// Existing project-detail behavior remains independent of pagination parameters.
if (!route[2]) return send(200, { id: projectId, name: project.name });
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit === null ? 25 : Number(rawLimit);
if (
url.searchParams.getAll("limit").length > 1 ||
(rawLimit !== null && !/^\d+$/.test(rawLimit)) ||
!Number.isInteger(limit) ||
limit < 1 ||
limit > 100
) {
return fail("invalid_limit");
}
let cursor;
if (url.searchParams.has("cursor")) {
try {
if (url.searchParams.getAll("cursor").length !== 1)
throw new Error("Invalid cursor");
cursor = decodeCursor(
url.searchParams.get("cursor"),
tenantId,
projectId,
signingKey
);
} catch {
return fail("invalid_cursor");
}
}
const matching = events
.filter(
(event) =>
event.tenant_id === tenantId && event.project_id === projectId
)
.sort(compareEvents)
.filter((event) => {
if (!cursor) return true;
// Deliberate baseline defect: timestamp-only continuation loses tied rows.
if (seededBug)
return (
timestampValue(event.created_at) < timestampValue(cursor.created_at)
);
return compareEvents(event, cursor) > 0;
});
const selected = matching.slice(0, limit);
const items = selected.map(({ id, created_at, type, summary }) => ({
id,
created_at,
type,
summary,
}));
const nextCursor =
matching.length > limit
? encodeCursor(selected.at(-1), tenantId, projectId, signingKey)
: null;
return send(200, { items, next_cursor: nextCursor });
});
}
export async function startApi(fixtures, options = {}) {
const server = createApi({ ...fixtures, ...options });
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(undefined));
});
const address = server.address();
if (!address || typeof address === "string")
throw new Error("No listening address");
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve(undefined)))
),
};
}初期データとプロジェクトのアクセス制御用フィクスチャ
export function createFixtures() {
const tokenA = randomBytes(24).toString("base64url");
const tokenB = randomBytes(24).toString("base64url");
const sessions = new Map([
[tokenA, "tenant_a"],
[tokenB, "tenant_b"],
]);
const projects = new Map([
["feed", { tenant_id: "tenant_a", name: "Activity feed" }],
["empty", { tenant_id: "tenant_a", name: "Empty feed" }],
["exact", { tenant_id: "tenant_a", name: "Four events" }],
["large", { tenant_id: "tenant_a", name: "Large feed" }],
["precision", { tenant_id: "tenant_a", name: "Precise timestamps" }],
["other", { tenant_id: "tenant_a", name: "Another project" }],
["private", { tenant_id: "tenant_b", name: "Private project" }],
]);
const event = (id, created_at, project_id, tenant_id = "tenant_a") => ({
id,
created_at,
project_id,
tenant_id,
type: "deployment",
summary: `Synthetic event ${id}`,
internal_note: "This field must never enter the HTTP response.",
raw_payload: { synthetic: true },
});
const feed = [
event("evt_105", "2026-01-15T12:00:03.123456Z", "feed"),
event("evt_104", "2026-01-15T12:00:02.123456Z", "feed"),
event("evt_103", "2026-01-15T12:00:02.123456Z", "feed"),
event("evt_102", "2026-01-15T12:00:02.123456Z", "feed"),
event("evt_101", "2026-01-15T12:00:01.123456Z", "feed"),
];
const events = [
...feed,
...feed.slice(0, 4).map((item) => ({ ...item, project_id: "exact" })),
...Array.from({ length: 105 }, (_, index) =>
event(
`large_${String(index).padStart(3, "0")}`,
new Date(Date.UTC(2026, 0, 15, 12, 0, index))
.toISOString()
.replace(".000Z", ".123456Z"),
"large"
)
),
// Reverse ID order makes timestamp precision observable in the tests.
event("precise_a", "2026-01-15T12:00:00.123999Z", "precision"),
event("precise_z", "2026-01-15T12:00:00.123001Z", "precision"),
event("other_1", "2026-01-15T12:00:00.123456Z", "other"),
event("other_2", "2026-01-15T11:59:59.123456Z", "other"),
event("private_1", "2026-01-15T12:00:00.123456Z", "private", "tenant_b"),
event("private_2", "2026-01-15T11:59:59.123456Z", "private", "tenant_b"),
];
return {
sessions,
projects,
events,
signingKey: randomBytes(32),
tokenA,
tokenB,
};
}HTTP API の仕様と回帰テスト
test("five-event traversal crosses a timestamp tie and replays the same continuation", async () => {
const expected = ["evt_105", "evt_104", "evt_103", "evt_102", "evt_101"];
const pages = await traverse("feed", 2, expected);
assert.deepEqual(pages.map(ids), [
["evt_105", "evt_104"],
["evt_103", "evt_102"],
["evt_101"],
]);
assert.equal(pages.length, 3);
assert.ok(pages[0].next_cursor);
assert.ok(pages[1].next_cursor);
assert.equal(pages[2].next_cursor, null);
const replay = await request("feed", {
limit: "2",
cursor: pages[0].next_cursor,
});
assert.deepEqual(replay.body.items, pages[1].items);
const next = await request("feed", {
limit: "2",
cursor: replay.body.next_cursor,
});
assert.deepEqual(next.body.items, pages[2].items);
assert.equal(next.body.next_cursor, null);
console.log(
JSON.stringify({
fixture: "five-event-tie",
pages: pages.map(ids),
requests: pages.length,
replay: ids(replay.body),
})
);
});
test("the same tie group traverses correctly with limit three", async () => {
const pages = await traverse("feed", 3, [
"evt_105",
"evt_104",
"evt_103",
"evt_102",
"evt_101",
]);
assert.deepEqual(pages.map(ids), [
["evt_105", "evt_104", "evt_103"],
["evt_102", "evt_101"],
]);
});
test("timestamp ordering and continuation preserve submillisecond precision", async () => {
const pages = await traverse("precision", 1, ["precise_a", "precise_z"]);
assert.equal(pages[0].items[0].created_at, "2026-01-15T12:00:00.123999Z");
assert.equal(pages[1].items[0].created_at, "2026-01-15T12:00:00.123001Z");
});
test("omitted, minimum, and maximum limits return 25, 1, and 100 of 105 events", async () => {
for (const [parameters, expected] of [
[{}, 25],
[{ limit: "1" }, 1],
[{ limit: "100" }, 100],
]) {
const result = await request("large", parameters);
assert.equal(result.status, 200);
assert.equal(result.body.items.length, expected);
assert.ok(result.body.next_cursor);
}
const pages = await traverse(
"large",
100,
Array.from(
{ length: 105 },
(_, index) => `large_${String(104 - index).padStart(3, "0")}`
)
);
assert.deepEqual(
pages.map((page) => page.items.length),
[100, 5]
);
});
test("invalid and repeated limits return the documented generic error", async (context) => {
for (const limit of [
"0",
"101",
"-1",
"1.5",
"many",
"",
"1e2",
"Infinity",
]) {
await context.test(`limit ${JSON.stringify(limit)}`, async () => {
const result = await request("feed", { limit });
assert.equal(result.status, 400);
assert.deepEqual(
result.body,
expectedError(
"invalid_limit",
"Limit must be an integer from 1 through 100."
)
);
});
}
const repeated = await request("feed", [
["limit", "1"],
["limit", "2"],
]);
assert.equal(repeated.status, 400);
});
test("empty, partial, and exactly full final pages have no continuation", async () => {
assert.deepEqual((await request("empty")).body, {
items: [],
next_cursor: null,
});
const partial = await traverse("feed", 2, [
"evt_105",
"evt_104",
"evt_103",
"evt_102",
"evt_101",
]);
assert.equal(partial.at(-1).items.length, 1);
const exact = await traverse("exact", 2, [
"evt_105",
"evt_104",
"evt_103",
"evt_102",
]);
assert.deepEqual(
exact.map((page) => page.items.length),
[2, 2]
);
assert.equal(exact[1].next_cursor, null);
});
test("authentication precedes malformed and wrong-scope cursor or limit validation", async () => {
const privateCursor = (
await request("private", { limit: "1" }, fixtures.tokenB)
).body.next_cursor;
for (const token of [null, "invalid_session"]) {
for (const parameters of [
{ cursor: "broken" },
{ cursor: privateCursor },
{ limit: "0", cursor: "broken" },
]) {
const result = await request("feed", parameters, token);
assert.equal(result.status, 401);
assert.deepEqual(result.body, unauthenticated);
}
}
});
test("inaccessible and nonexistent projects return identical 404 before cursor validation", async () => {
const ownCursor = (await request("feed", { limit: "1" })).body.next_cursor;
for (const project of ["private", "missing"]) {
for (const parameters of [
{},
{ cursor: "broken" },
{ cursor: ownCursor },
{ limit: "0", cursor: "broken" },
]) {
const result = await request(project, parameters);
assert.equal(result.status, 404);
assert.deepEqual(result.body, notFound);
}
}
});
test("a valid cursor from another project in the same tenant is invalid on an authorized feed", async () => {
const cursor = (await request("other", { limit: "1" })).body.next_cursor;
assert.ok(cursor);
const result = await request("feed", { cursor });
assert.equal(result.status, 400);
assert.deepEqual(result.body, invalidCursor);
});
test("a valid cursor from another tenant is invalid on an authorized feed", async () => {
const cursor = (await request("private", { limit: "1" }, fixtures.tokenB))
.body.next_cursor;
assert.ok(cursor);
const result = await request("feed", { cursor });
assert.equal(result.status, 400);
assert.deepEqual(result.body, invalidCursor);
});
test("undecodable, empty, tampered, and repeated cursor tokens return the same closed error", async () => {
const cursor = (await request("feed", { limit: "1" })).body.next_cursor;
const [payload, signature] = cursor.split(".");
const tampered = `${payload}.${signature[0] === "A" ? "B" : "A"}${signature.slice(1)}`;
for (const token of [
"broken",
"",
"%%%.___",
"a.b.c",
"x".repeat(2049),
tampered,
]) {
const result = await request("feed", { cursor: token });
assert.equal(result.status, 400);
assert.deepEqual(result.body, invalidCursor);
}
const result = await request("feed", [
["cursor", cursor],
["cursor", cursor],
]);
assert.equal(result.status, 400);
assert.deepEqual(result.body, invalidCursor);
});
test("signed structurally invalid payloads and unsupported versions are rejected", async (context) => {
const valid = {
version: 1,
tenant_id: "tenant_a",
project_id: "feed",
created_at: "2026-01-15T12:00:02.123456Z",
id: "evt_104",
};
const missing = { ...valid };
delete missing.id;
const cases = [
["missing ID", missing],
["number ID", { ...valid, id: 104 }],
["empty ID", { ...valid, id: "" }],
["number timestamp", { ...valid, created_at: 0 }],
["invalid date", { ...valid, created_at: "2026-02-30T12:00:00Z" }],
["unsupported version", { ...valid, version: 2 }],
["extra field", { ...valid, extra: true }],
["wrong tenant type", { ...valid, tenant_id: 1 }],
["wrong project type", { ...valid, project_id: null }],
["null payload", null],
["array payload", []],
["invalid JSON", "not-json"],
];
for (const [name, payload] of cases) {
await context.test(name, async () => {
const result = await request("feed", { cursor: signFixture(payload) });
assert.equal(result.status, 400);
assert.deepEqual(result.body, invalidCursor);
});
}
});
test("every serialized event omits stored tenant, project, and raw payload fields", async () => {
const result = await request("feed", { limit: "100" });
assert.equal(result.status, 200);
for (const item of result.body.items) {
assert.deepEqual(Object.keys(item).sort(), [
"created_at",
"id",
"summary",
"type",
]);
}
assert.ok(
fixtures.events.some((event) => event.internal_note && event.raw_payload)
);
});
test("the schema oracle rejects extra, missing, incorrectly typed, and malformed public fields", () => {
const schema = specification.components.schemas.EventPage;
const page = {
items: [
{
id: "evt_1",
created_at: "2026-01-15T12:00:00.123456Z",
type: "deployment",
summary: "Synthetic",
},
],
next_cursor: null,
};
assert.ok(conforms(page, schema));
assert.equal(conforms({ ...page, tenant_id: "tenant_a" }, schema), false);
assert.equal(
conforms(
{ ...page, items: [{ ...page.items[0], raw_payload: {} }] },
schema
),
false
);
assert.equal(conforms({ items: page.items }, schema), false);
assert.equal(conforms({ ...page, next_cursor: "" }, schema), false);
assert.equal(
conforms({ ...page, items: [{ ...page.items[0], id: 1 }] }, schema),
false
);
assert.equal(
conforms(
{ ...page, items: [{ ...page.items[0], created_at: "yesterday" }] },
schema
),
false
);
const errorSchema = specification.components.schemas.InvalidCursor;
assert.equal(
conforms(
{ error: { ...invalidCursor.error, stack: "internal" } },
errorSchema
),
false
);
assert.equal(
conforms(
{ error: { ...invalidCursor.error, message: "tenant_a" } },
errorSchema
),
false
);
});
test("the existing project-detail route preserves access and ignores pagination parameters", async () => {
const own = await request(
"feed",
{ cursor: "broken", limit: "0" },
fixtures.tokenA,
true
);
assert.equal(own.status, 200);
assert.deepEqual(own.body, { id: "feed", name: "Activity feed" });
const inaccessible = await request("private", {}, fixtures.tokenA, true);
assert.equal(inaccessible.status, 404);
assert.deepEqual(inaccessible.body, notFound);
const unauthenticatedResult = await request("feed", {}, null, true);
assert.equal(unauthenticatedResult.status, 401);
assert.deepEqual(unauthenticatedResult.body, unauthenticated);
});
test("OpenAPI declares operation, limit boundaries, signed cursor, and response statuses", () => {
assert.equal(specification.openapi, "3.1.0");
const operation = specification.paths["/v1/projects/{project_id}/events"].get;
assert.equal(operation.operationId, "listProjectEvents");
const limit = operation.parameters.find(
(parameter) => parameter.name === "limit"
).schema;
assert.deepEqual(limit, {
type: "integer",
minimum: 1,
maximum: 100,
default: 25,
});
assert.deepEqual(Object.keys(operation.responses).sort(), [
"200",
"400",
"401",
"404",
]);
assert.equal(
specification.components.securitySchemes.session.scheme,
"bearer"
);
});検証記録の保存
mkdirSync(output, { recursive: true });
const states = [];
for (const [name, arguments_] of [
["red", ["--seeded"]],
["green", []],
]) {
const result = spawnSync(
process.execPath,
[resolve(directory, "test.mjs"), ...arguments_],
{
cwd: directory,
encoding: "utf8",
timeout: 30_000,
}
);
writeFileSync(
resolve(output, `${name}.log`),
`${result.stdout ?? ""}${result.stderr ?? ""}`
);
states.push({
name,
command: `node test.mjs${arguments_.length ? " --seeded" : ""}`,
exit_code: result.status,
signal: result.signal,
error: result.error?.message ?? null,
});
}
const sources = Object.fromEntries(
readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => [
entry.name,
createHash("sha256")
.update(readFileSync(resolve(directory, entry.name)))
.digest("hex"),
])
);
const observed = { red: await observe(true), green: await observe(false) };
writeJson("observed-http.json", observed);
writeJson("run-manifest.json", {
execution: "Synthetic loopback HTTP fixture; no external service",
recorded_at: new Date().toISOString(),
node: process.version,
minimum_node: "22.6.0",
dependencies: "Node standard library only; no install step",
baseline:
"Deliberately seeded timestamp-only continuation; not a historical customer defect",
correction:
"Descending timestamp-and-ID tuple continuation using full fractional precision",
source_sha256: sources,
states,
});
if (
states[0].exit_code === null ||
states[0].exit_code === 0 ||
states[1].exit_code !== 0
) {
throw new Error(
"Expected the unchanged tests to fail on the seeded baseline and pass on the corrected implementation. Inspect red.log and green.log."
);
}
console.log(
JSON.stringify(
{
execution: "synthetic-loopback-http",
red_exit: states[0].exit_code,
green_exit: states[1].exit_code,
green_pages: observed.green.pages.map((page) =>
page.body.items.map((item) => item.id)
),
evidence_directory: output,
},
null,
2
)
);タスクに渡す情報の準備
issue のリンクを渡す場合は、Codex がリンク先を読めることを確認してください。アクセスできない場合は、該当する内容を貼り付けるか添付します。最初のプロンプトには、以下の情報を添えてください。
| 入力 | サンプルの issue |
|---|---|
| issue と期待する結果 | API-142:GET /v1/projects/{project_id}/events を追加し、利用者がアクティビティフィードをページ単位で取得できるようにします。 |
| リポジトリとベース | 選択したリポジトリにサンプルフォルダーを配置し、タスクで使用するブランチと正確なコミットを記録します。 |
| 関連コード | サンプルの api.mjs、fixtures.mjs、test.mjs、openapi.json と、リポジトリの AGENTS.md |
| 既存の実装パターン | ページネーションのパラメーターを検証する前に、認証とプロジェクトへのアクセス権の確認を行います。署名付きカーソルと、仕様に記載された公開エラーレスポンスの形式を使用します。自分のリポジトリに合わせて issue を調整する際は、既存のヘルパーに従います。 |
| テストの準備 | メモリ上に 2 つのテナントの固定テストプロジェクトを用意し、テナント A には 6 つのプロジェクトを配置します。同じタイムスタンプのイベントがページ境界をまたぐケース、秒未満の精度を持つタイムスタンプ、空の最終ページ、上限ちょうどの最終ページ、件数上限の検証用の 105 件のイベントを用意します。 |
| 検証 | node test.mjs --seeded(失敗を想定)、node test.mjs(修正後の状態)、node reproduce.mjs ./evidence を実行します。node --check は各 .mjs ファイルに対して実行します。自分のサービスでは、実際に使用する型チェック、lint、回帰テスト、CI のコマンドも追加します。 |
| 対象範囲 | エンドポイント、その動作に絞ったテスト、対応する OpenAPI ドキュメントを対象とします。依存関係の追加、データベースの移行、フロントエンド、デプロイ、認証・認可の再設計は対象外です。 |
| 引き渡し | 利用できるリポジトリ連携がドラフト PR の作成に対応し、依頼者が作成を許可している場合はドラフト PR を提出します。それ以外の場合は、差分と PR 説明文の下書きを提出します。 |
サンプルには Node 22.6 以降を用意してください。テストは専用のセッションとカーソル署名鍵を生成し、ループバックインターフェイスで一時的な HTTP サーバーを起動します。自分のサービスでは、依存関係、テスト設定、隔離されたサービスも準備します。この issue を割り当てる前に、既存のカーソルヘルパーでカーソルを認証済みテナントとリクエスト対象のプロジェクトに紐づけられることを確認してください。
API 仕様の定義
実装前に、以下のサンプル仕様が自分のリポジトリに合っていることを確認します。成功時は HTTP 200 を返し、レスポンスの構造は厳密に { items, next_cursor } とします。
各項目が持つ公開フィールドは、必須の 4 つだけです。id(空でない文字列)、created_at(UTC の RFC 3339 形式のタイムスタンプ文字列)、type(空でない文字列)、summary(文字列)です。items はこれらのオブジェクトの配列です。next_cursor はクライアントが内部構造を解釈する必要のない空でない文字列、または null です。
テナントやプロジェクトの内部キー、データベースのメタデータ、イベントの生のペイロード、認証情報はレスポンスに含めません。レスポンスの各フィールドの型、必須フィールド、null を許容するかどうか、各オブジェクトに追加のプロパティを許可しないことを OpenAPI に明記します。シリアライズされた HTTP レスポンス本文がそのスキーマに適合することを検証します。
イベントは created_at DESC, id DESC の順に並べます。タイムスタンプは秒未満の精度を保持し、ID は辞書順の降順で比較します。カーソルを使った取得は、この順序で直前に返した最後の組を含めず、その次から再開します。
デフォルトのページサイズは 25 件です。件数上限には 1 から 100 までの 10 進整数を指定できます。件数上限またはカーソルのパラメーターを重複して指定すると無効になります。条件に一致するイベントがまだ残っている場合に限り、null 以外のカーソルを返します。
最終ページでは、件数が上限未満でも上限ちょうどでも next_cursor: null を返します。アクセス権のある空のプロジェクトでは items: [] と next_cursor: null を返します。
サンプルでは各カーソルに署名し、認証済みテナントとリクエスト対象のプロジェクトに紐づけます。件数上限やカーソルを検証する前に、認証と対象プロジェクトへのアクセス権の確認を行います。この順序により、パラメーターエラーから別のテナントのプロジェクトが存在するかどうかを推測できなくなります。
セッションが指定されていない場合や無効な場合は 401 を返します。アクセスできないプロジェクトと存在しないプロジェクトには、同じ汎用の 404 レスポンスを返します。アクセス権を確認した後、カーソルより先に件数上限を検証します。
カーソルの形式が正しくない場合、改ざんされている場合、内部構造が無効な場合、テナントまたはプロジェクトが一致しない場合は、HTTP 400 とエラーコード invalid_cursor を返します。アクセス権のあるプロジェクトへのリクエストでも、同じテナントの別のプロジェクトや別のテナント向けに発行された有効なカーソルを指定すると、そのカーソルは無効です。
アクセスできないプロジェクトに対する認可の結果は、指定されたカーソルによって変わってはいけません。サンプルのバージョン 1 のカーソルには有効期限がありません。サポート対象外のバージョンは無効です。
サンプルでは、すべてのエラーレスポンス本文の構造を厳密に { error: { code, message } } とします。両フィールドとも必須の文字列で、追加のフィールドは許可しません。
以下の汎用エラーレスポンスを使用します。
- 400
invalid_cursor/Invalid cursor. - 400
invalid_limit/Limit must be an integer from 1 through 100. - 401
unauthenticated/Authentication required. - 404
not_found/Project not found.
デコードしたカーソルの内容、テナントやプロジェクトの識別子、生のエラー、スタックトレースは、どのエラーレスポンス本文にも含めません。実際のリポジトリで承認されているレスポンス形式やエラーコードが異なる場合は、そちらを使用します。共有ヘルパーを変更する前に、この仕様の違いを解消してください。
全ページの取得と同じカーソルでの再取得は、並行して書き込みが行われない固定フィクスチャで検証します。カーソルを順にたどると、すべてのイベントが仕様どおりの順序で 1 回ずつ返ります。空でないフィードでは ceil(event_count / limit) 回以内、空のフィードでは 1 回のリクエストで取得が完了します。
フィクスチャが変わらない間は、同じリクエストパラメーターで有効なカーソルを再使用すると、同じ項目と同じ再開位置が返ります。署名付きトークンがバイト単位で一致する必要はありません。
この仕様では、カーソルが 1 回限りであること、スナップショットに基づくページネーション、フィード変更後の結果の一致は保証しません。リポジトリで既に採用しているフィードの整合性方針を文書化してください。方針が未定で、issue がスナップショットに基づく動作を求めている場合は、作業を止めて製品仕様の判断を仰ぎます。
実施手順
-
issue を選び、環境を準備します。 上記の仕様が実際のリポジトリに合っていることを確認します。準備済みのクラウド環境と対象のベースコミットを選び、issue の情報をタスクに直接入力します。この手順は、issue トラッカーから自動で起動しなくても開始できます。
-
最初のプロンプトを実行します。 角括弧内を、リポジトリ、issue の情報、作業範囲、チェック内容に置き換えます。Codex に差分を返してもらうか、ドラフト PR の作成を許可して依頼するかを選びます。このページの開始用プロンプトには、引き渡しに必要な指示がすべて含まれています。
-
変更前の状態を確認します。 Codex にリポジトリの指示を読ませ、編集前に承認済みのベースラインチェックを実行してもらいます。
サンプルでは、
node test.mjsとREADME.mdに記載された構文チェックを実行します。--seededは意図的に失敗を再現するために別途指定します。自分のサービスでは、既存の回帰テスト、型チェック、lint を実行します。新しいエンドポイント専用のテストファイルがまだなければ、これから作成するテストとして記録します。変更後の結果が新機能によるものかを判断する前に、既存のチェックの失敗やテスト用サービスを利用できない問題を解消してください。
-
仕様を実装し、検証します。 既存のアクセス制御用ヘルパーとカーソルヘルパーを使ってエンドポイントを実装し、以下の受け入れテストを追加して API ドキュメントを更新するよう Codex に依頼します。可能な限り、新しいテストが元のコードでは失敗し、変更後は成功することを確かめ、その結果を最終的な検証記録と一緒に保存します。ダウンロードできるサンプルでは、
--seededを指定して意図的に仕込んだ不具合を有効にし、修正後の結果と比較します。この比較は、自分のリポジトリでベースラインチェックが失敗する場合とは区別してください。 -
変更内容と検証記録をレビューします。 差分、受け入れ条件のチェック、チェックの出力、残っているリスクを照らし合わせます。作成を許可したドラフト PR を Codex が提出した場合は、ベースとヘッドが正しいことを確認します。PR 作成ツールが利用できない場合は、返された差分と説明文を使い、通常の手順で PR を準備します。未解決のチェックはドラフト PR に列挙し、マージ可能な状態にしないでください。
-
レビューの指摘に対応し、通常の手順でマージします。 同じタスクで具体的なレビューコメントを Codex に渡し、対応後に影響を受けるチェックを再実行します。レビュアーが必須の CI チェックの結果を確認し、最終コミットを承認してからマージします。
必須の受け入れテスト
以下のテストで対象範囲と期待する動作を明確にしておくと、issue を任せやすくなり、結果もレビューしやすくなります。各テストで確認する動作は維持し、名前は使用するテストランナーに合わせてください。
全ページを安定した順序で取得できること
固定のイベントを 5 件用意します。evt_105 のタイムスタンプが最も新しく、evt_104、evt_103、evt_102 のタイムスタンプは同じで、evt_101 が最も古くなるようにします。
limit=2 の場合は、各ページが順に [evt_105, evt_104]、[evt_103, evt_102]、[evt_101] と完全に一致することを確認します。同じタイムスタンプのイベントが最初のページ境界をまたぐ必要があります。
順序が正確で、ID の重複や欠落がなく、最初の 2 ページのカーソルは null 以外、3 ページ目のカーソルは null であることを検証します。取得に 4 回以上のリクエストが必要になった場合はテストを失敗させます。
フィクスチャを変更せず、同じ件数上限で最初のページのカーソルを再使用します。2 ページ目の項目と、その次の再開位置が元の結果と一致することを検証します。
デフォルトの件数と境界値
100 件を超えるイベントを用意します。limit パラメーターを省略すると 25 件、limit=1 では 1 件、limit=100 では 100 件が返ることを検証します。
無効な件数上限
0、101、-1、小数、many などの文字列を指定すると、既存の検証エラー形式で HTTP 400 が返ることを検証します。
空のページと最終ページ
アクセス権のある空のプロジェクトでは items: [] と next_cursor: null が返ることを検証します。limit=2 の場合、イベントが 5 件なら 3 ページ目に 1 件、4 件なら 2 ページ目に 2 件が含まれ、いずれもカーソルが null になることを確認します。最終ページの件数が上限ちょうどでも、空のページを取得するための追加リクエストは不要です。
認証
セッションが指定されていない場合や無効な場合は、仕様どおりのスキーマで 401 レスポンスが返ることを検証します。形式が正しくないカーソルや別のスコープのカーソルを指定したリクエストも対象です。カーソルの検証結果が認証結果に優先してはいけません。
テナント間の分離
テナント A として認証し、テナント B のプロジェクトまたは存在しないプロジェクトをリクエストします。形式が正しくないカーソルやスコープが一致しないカーソルを指定した場合も含め、いずれの場合も仕様で定めた同じ汎用 404 レスポンス本文が返り、イベントデータが含まれないことを検証します。認可はカーソルの検証より先に行う必要があります。
カーソルのスコープ
アクセス権のあるテナント A のプロジェクトに対して、(1)テナント A の別のプロジェクトの有効な署名付きカーソルと、(2)テナント B で発行された有効な署名付きカーソルを使い、それぞれリクエストします。
どちらも HTTP 400 と同じ invalid_cursor レスポンス本文を返し、イベントデータやスコープの詳細が含まれないことを検証します。アクセスできないプロジェクトへのリクエストは別にテストし、404 が返ることを確認します。
不正な形式のカーソルと改ざんされたカーソル
アクセス権のあるプロジェクトで、ヘルパーがデコードできないトークン、署名が書き換えられたトークン、カーソルの必須フィールドが欠けているか型が正しくない署名済みトークンを指定します。いずれも HTTP 400 と invalid_cursor が返ることを検証します。署名には他から隔離したテスト専用のフィクスチャを使用します。
共有ヘルパーにバージョンや有効期限を検証する機能があれば、サポート対象外のバージョンや期限切れのトークンもテストします。レスポンスが合意済みの汎用エラースキーマに一致し、デコードした内容、識別子、生のエラー、スタックトレースが含まれないことを検証します。
既存の動作
サンプルでは、既存のプロジェクト詳細ルートに対して、アクセスが許可されるリクエスト、認証に失敗するリクエスト、アクセスできないプロジェクトへのリクエストを検証します。サンプルの .mjs ファイルには構文チェックを実行します。自分のサービスでは、既存の回帰テストスイート、型チェック、lint を実行します。
公開レスポンスのスキーマ
実際の HTTP 200、400、401、404 レスポンス本文を、合意済みの OpenAPI スキーマに照らして検証します。必須キー、各項目のフィールドの型、タイムスタンプの形式、カーソルが null を許容するかどうかが仕様どおりで、どの階層のオブジェクトにも余分なプロパティがないことを確認します。
保存するイベントに内部フィールドをあらかじめ含め、シリアライズされた項目には出力されないことを検証します。すべてのエラーレスポンスに、承認済みの汎用エラーコードとメッセージだけが含まれることも確認します。
ドキュメント
OpenAPI の操作定義と例が、実装したパラメーター、成功時とエラー時の厳密なスキーマ、認可を優先する順序、イベントの並び順、最終ページの規則、固定フィクスチャでの再取得と整合性の保証範囲に一致することを確認します。リポジトリに API 仕様の検証手順があれば実行します。
成果物の例
レビュー用の検証記録
受け入れ条件ごとに以下の形式で、実際の出力に基づいて記録します。変更前には存在しない新規テストについては、「まだ存在しない」ことと、既存のチェックを実行して失敗したことを区別してください。
受け入れ条件とテスト:[検証する具体的な動作とテストの場所]
環境:[公開済み環境の設定の参照先、ランタイム・データベース・依存関係の各バージョン]
変更前:[元のリビジョンとテストのみの差分 ID、実行コマンド、終了コード、アサーションの実際の結果、ログのパス]
変更後:[テストした状態(最終リビジョン、またはベースリビジョンと差分 ID)、実行コマンド、終了コード、アサーションの実際の結果、ログのパス]
状態:[成功 / 失敗 / 実行不能 / 未実行]
検証の限界とレビュアーに求める対応:[未検証の動作、実行を妨げている問題、または必要な判断]
失敗した実行と成功した実行は、それぞれ実際にテストしたコードの状態と対応づけます。元のリビジョンにテストだけを追加した場合は、その差分も記録します。引き渡す際には、ログ、レスポンスのフィクスチャ、最終的な差分を揃えてください。ファイル名やテスト名だけでは実行したことを確認できません。テスト対象のリビジョンが変わったら、検証記録を更新します。実行できなかったチェックや未実行のチェックを成功として数えてはいけません。
PR の説明文では、記載した各結果に対応する検証記録へのリンクを示します。以下の引き渡し用テンプレートを使用し、角括弧内のすべての欄を実際に確認した結果で置き換えてください。
タイトル:プロジェクトのイベント取得にカーソルベースのページネーションを追加
対象 issue:API-142
ベース:[ブランチとコミット]
テストした状態:[ヘッドコミット、またはベースコミットと添付した差分の識別子]
変更内容
- 既存のプロジェクトアクセス制御用ヘルパーを使い、プロジェクトのイベントを取得するエンドポイントを追加
- テナントとプロジェクトに紐づく既存の署名付きカーソルを再利用
- API 仕様を検証するテストを追加し、OpenAPI の操作定義を更新
受け入れ条件の検証記録
- [上記の受け入れ条件ごとに 1 行ずつ、対応するテスト、実際の結果、ログの参照先を記入します。]
検証結果
- 編集前:[ベースラインチェックごとに、コマンド、結果、ログの参照先を記入します。]
- 元のコードに対する新規テスト:[結果を記入します。未実行の場合はその理由を記入します。]
- 編集後:[必須のチェックごとに、コマンド、結果、ログの参照先を記入します。]
- CI:[各チェックへのリンクと実際の状態を記入します。利用できない場合はその旨を記入します。]
- 残っているチェック:[具体的なチェックと実行を妨げている問題を記入します。残っていない場合は、その旨と根拠を記入します。]
レビューで重点的に確認する点
- [最もリスクの高い動作と、未解決の受け入れ条件を記入します。]
残っているリスクとロールバック
- [実際に確認した制約を、未検証の仮定と区別して記入します。]
- [この変更を元に戻す方法と、この変更に依存する呼び出し元の有無を記入します。]
レビューコメントへの対応
具体的な指摘には、対応内容を絞った追加のプロンプトを使用します。たとえば、次のように依頼します。
今回の変更で、次のレビューコメントに対応してください。
「同じ 5 件のイベントを limit=3 で最初から順に取得してください。1 ページ目が [evt_105, evt_104, evt_103]、2 ページ目が [evt_102, evt_101] となり、2 ページ目で返るカーソルが null であることを検証してください。」
このケースの回帰テストを追加してください。そのテストで不具合が確認された場合に限り、実装を変更してください。
対象のエンドポイントのテストと、今回の変更の影響を受けるその他の必須チェックを再実行してください。
検証記録の表を更新し、テストしたコードの状態と実際の結果を記入してください。
作業範囲は変更しないでください。マージもデプロイも行わないでください。
Codex がレビューを直接参照できない場合は、コメントの本文を渡してください。レビュアーはコメントの文面だけで判断せず、指摘した動作が実際に修正されたことを確認します。
作業の中断条件
- ベースラインチェックが失敗するか、実行できない場合。 失敗したチェックとセットアップの詳細を報告します。環境を修正するか、基準とする状態について合意して文書化してから作業を再開します。
- 仕様が既存の動作と矛盾する場合。 矛盾する点、影響を受ける呼び出し元、解決に必要な最小限の判断を明らかにします。断りなく仕様を変更してはいけません。
- 移行、依存関係の追加、認証・認可の再設計が必要な場合。 理由を説明し、作業を広げる前に issue を分割するか、対象範囲を見直します。
- 実装後にテストが失敗する場合。 マージ可能とは判断せず、失敗したケースを報告します。チェックを通すためにアサーションを削除したり、認可を緩めたりしてはいけません。
- リポジトリへの書き込み連携を利用できない場合。 差分、PR 説明文の下書き、検証記録を返します。チームメンバーがリポジトリ側の引き渡し作業を完了できます。
ワークフローの再利用
最初の成果物がレビュアーに承認されたら、タスクに渡した issue の情報と受け入れ条件のチェックリストを、チームの issue テンプレートに保存します。テストコマンドや API 仕様の保管場所など、継続して使うリポジトリ固有の指示は AGENTS.md または既存のコントリビューターガイドに記載してください。
Related use cases
Codex が使える CLI の作成
どのフォルダーからでも実行でき、リポジトリ内のスクリプトと組み合わせたり、ファイルをダウンロードしたりできる CLI の作成を Codex...
インシデントの調査と修正の検証
機密情報を取り除いたインシデントの調査資料と、テストを実行できる環境を Codex Cloud に渡します。Codex Cloud...
コードの移行
Codex を使って、レガシーシステムの各要素が新しいスタックのどの要素に対応するかを整理します。移行は段階的に進め、次の段階に移る前に、新旧システムの動作が一致することを確認します。