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
19 changes: 18 additions & 1 deletion docs/HARNESS_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,31 @@ GitHub App 접근 권한은 해당 레포에서 GitHub API를 호출할 수 있
- `.codex/skills/**` (참고 문서·스크립트 포함)
- `.claude/skills/**` (참고 문서·스크립트 포함)
- `.agent/skills/**` (참고 문서·스크립트 포함)
- `.gitattributes`
- `.gitattributes` (Git LFS 등 저장소 고유 규칙을 보존하고 하네스 관리 블록만 갱신)
- `.github/ISSUE_TEMPLATE/{01-feat,02-fix,03-chore,04-refactor}.yml`
- `.github/pull_request_template.md`

GitHub App 설치 토큰은 GitHub Actions workflow 파일을 만들 수 없으므로 `.github/workflows/assign-issue-author.yml`은 자동 동기화 대상에서 제외해요. Discord 알림 코드, 배포 설정, 대상 레포의 고유 스킬도 동기화하지 않아요.

`AGENTS.md` 또는 `CLAUDE.md`가 이미 있는 대상 레포는 해당 파일을 덮어쓰지 않아요. `framework-collaboration-harness` 관리 마커 사이의 섹션만 갱신하므로 제품 고유 지시는 유지돼요.

`.gitattributes`는 Git 속성 파일에 맞는 `#` 주석 마커를 사용해요. 로컬 적용과 GitHub App 모두 아래 블록만 갱신하고, 블록 밖의 LFS 규칙·주석·빈 줄과 상대 순서는 보존해요.

```gitattributes
# framework-collaboration-harness:start
- text=auto eol=lf

*.md text eol=lf linguist-detectable
*.yaml text eol=lf linguist-detectable
*.yml text eol=lf linguist-detectable
*.json text eol=lf linguist-detectable
# framework-collaboration-harness:end
```

마커가 없는 기존 파일에서는 이전에 배포한 하네스 규칙 다섯 줄과 정확히 일치하는 줄만 제거하고, 첫 일치 위치에 관리 블록을 넣어요. 공백이나 속성이 다른 규칙은 저장소 고유 내용으로 유지하며, 빈 줄도 삭제하지 않아요. 이전 규칙 목록은 `sync/attributes.ts`에 고정하여 원본 규칙이 바뀌어도 이전을 지원해요. 일치하는 줄이 없으면 기존 내용 뒤에 블록을 추가하고, 파일이 없으면 블록으로 새 파일을 만들어요.

재실행은 동일한 내용을 만들어요. 모든 동기화 대상 파일이 같으면 GitHub App은 커밋·브랜치·PR을 만들지 않아요. Actions 경로도 변경 파일이 없으면 PR 생성을 건너뛰어요.

같은 대상 레포에 열린 동기화 PR이 있으면 새 PR을 만들지 않아요. PR을 머지하거나 닫은 다음 실행에서 최신 원본으로 다시 동기화해요.

자동 동기화 PR은 작업 이슈가 없는 봇 작업이므로 일반 PR의 이슈 번호 규칙 대신 `chore: framework-agent-harness-sync` 제목을 사용해요.
Expand Down
6 changes: 4 additions & 2 deletions sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cp, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mergeManagedInstructions } from "./instructions.ts";
import { mergeManagedAttributes } from "./attributes.ts";
import { syncItems, type SyncItem } from "./manifest.ts";

function containedPath(root, path) {
Expand All @@ -25,14 +26,15 @@ export async function applyHarnessFiles({ sourceRoot, targetRoot, items = syncIt
const sourceStat = await lstat(source);

await mkdir(dirname(destination), { recursive: true });
if (item.mode === "append-managed-instructions") {
if (item.mode === "append-managed-instructions" || item.mode === "merge-managed-attributes") {
let existing = "";
try {
existing = await readFile(destination, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
await writeFile(destination, mergeManagedInstructions(existing, await readFile(source, "utf8")));
const merge = item.mode === "merge-managed-attributes" ? mergeManagedAttributes : mergeManagedInstructions;
await writeFile(destination, merge(existing, await readFile(source, "utf8")));
copied.push(item.destination);
continue;
}
Expand Down
46 changes: 46 additions & 0 deletions sync/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { mergeManagedInstructions } from "./instructions.ts";

export const MANAGED_ATTRIBUTES_MARKERS = {
start: "# framework-collaboration-harness:start",
end: "# framework-collaboration-harness:end"
};

// Keep the exact pre-marker rules even when the source rules change in the future.
const legacyHarnessLines = new Set([
"- text=auto eol=lf",
"*.md text eol=lf linguist-detectable",
"*.yaml text eol=lf linguist-detectable",
"*.yml text eol=lf linguist-detectable",
"*.json text eol=lf linguist-detectable"
]);

export function mergeManagedAttributes(existing = "", attributes: string) {
const markers = MANAGED_ATTRIBUTES_MARKERS;
const lines = existing.split(/(?<=\n)/);
let start = -1;
let offset = 0;
for (const line of lines) {
const text = line.replace(/\r?\n$/, "");
if (text === markers.start) start = offset;
if (text === markers.end && start >= 0) {
const end = offset + line.length;
return existing.slice(0, start)
+ mergeManagedInstructions(existing.slice(start, end), attributes, markers)
+ existing.slice(end);
}
offset += line.length;
}

const block = mergeManagedInstructions("", attributes, markers);
let inserted = false;
// Retain line endings and whitespace for every line not owned by the harness.
const migrated = lines.map((line) => {
if (!legacyHarnessLines.has(line.replace(/\r?\n$/, ""))) return line;
if (inserted) return "";
inserted = true;
return block;
}).join("");

if (inserted) return migrated;
return `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${block}`;
}
6 changes: 4 additions & 2 deletions sync/github-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { selectTargets } from "./discover.ts";
import { mergeManagedInstructions } from "./instructions.ts";
import { mergeManagedAttributes } from "./attributes.ts";
import { syncPullRequestBody } from "./pr-body.ts";
import { syncBranch, syncCommitMessage, syncItems, syncPullRequestTitle, type SyncItem } from "./manifest.ts";

Expand Down Expand Up @@ -128,14 +129,15 @@ async function createSyncPullRequest({ client, target, files }) {
const baseTree = await client.request(`/repos/${target.repository}/git/trees/${baseCommit.tree.sha}?recursive=1`);
const existingBlobs = new Map(baseTree.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
const resolvedFiles = await Promise.all(files.map(async (file) => {
if (file.mode !== "append-managed-instructions") return file;
if (file.mode !== "append-managed-instructions" && file.mode !== "merge-managed-attributes") return file;
const existingSha = existingBlobs.get(file.path);
let existing = "";
if (existingSha) {
const blob = await client.request(`/repos/${target.repository}/git/blobs/${existingSha}`);
existing = Buffer.from(blob.content, blob.encoding || "base64").toString("utf8");
}
const content = Buffer.from(mergeManagedInstructions(existing, file.content.toString("utf8")));
const merge = file.mode === "merge-managed-attributes" ? mergeManagedAttributes : mergeManagedInstructions;
const content = Buffer.from(merge(existing, file.content.toString("utf8")));
return { ...file, content, sha: gitBlobSha(content) };
}));
const changedFiles = resolvedFiles.filter((file) => existingBlobs.get(file.path) !== file.sha);
Expand Down
13 changes: 8 additions & 5 deletions sync/instructions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
export const MANAGED_INSTRUCTIONS_START = "<!-- framework-collaboration-harness:start -->";
export const MANAGED_INSTRUCTIONS_END = "<!-- framework-collaboration-harness:end -->";

export function mergeManagedInstructions(existing = "", instructions) {
const block = `${MANAGED_INSTRUCTIONS_START}\n${instructions.trim()}\n${MANAGED_INSTRUCTIONS_END}\n`;
const start = existing.indexOf(MANAGED_INSTRUCTIONS_START);
const end = existing.indexOf(MANAGED_INSTRUCTIONS_END, start);
export function mergeManagedInstructions(existing = "", instructions, markers = {
start: MANAGED_INSTRUCTIONS_START,
end: MANAGED_INSTRUCTIONS_END
}) {
const block = `${markers.start}\n${instructions.trim()}\n${markers.end}\n`;
const start = existing.indexOf(markers.start);
const end = existing.indexOf(markers.end, start);

if (start >= 0 && end >= start) {
return `${existing.slice(0, start)}${block}${existing.slice(end + MANAGED_INSTRUCTIONS_END.length).replace(/^\n?/, "")}`;
return `${existing.slice(0, start)}${block}${existing.slice(end + markers.end.length).replace(/^\r?\n/, "")}`;
}

return existing.length === 0 ? block : `${existing.replace(/\s*$/, "")}\n\n${block}`;
Expand Down
3 changes: 2 additions & 1 deletion sync/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const syncItems: SyncItem[] = [
{
id: "git-attributes",
source: ".gitattributes",
destination: ".gitattributes"
destination: ".gitattributes",
mode: "merge-managed-attributes"
},
...issueTemplates.map((name) => ({
id: "issue-template-" + name,
Expand Down
76 changes: 76 additions & 0 deletions tests/attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { applyHarnessFiles } from "../sync/apply.ts";
import { mergeManagedAttributes } from "../sync/attributes.ts";
import { syncItems } from "../sync/manifest.ts";

const source = await readFile(new URL("../.gitattributes", import.meta.url), "utf8");
const block = `# framework-collaboration-harness:start\n${source.trim()}\n# framework-collaboration-harness:end\n`;
const clientLfs = "apps/android/InnoLive/app/src/main/assets/privacy-face.onnx filter=lfs diff=lfs merge=lfs -text\n"
+ "apps/android/InnoLive/app/src/main/assets/privacy-face.tflite filter=lfs diff=lfs merge=lfs -text\n";
const aiLfs = "*.pt filter=lfs diff=lfs merge=lfs -text\n";
const staleBlock = "# framework-collaboration-harness:start\n*.old text\n# framework-collaboration-harness:end\n";

const cases = [
{ name: "파일이 없는 새 저장소", existing: undefined, expected: block },
{ name: "빈 파일", existing: "", expected: block },
{ name: "LFS 규칙만 있는 파일", existing: aiLfs, expected: aiLfs + block },
{ name: "마지막 개행이 없는 파일", existing: aiLfs.trimEnd(), expected: aiLfs + block },
{ name: "마커 없는 기존 하네스 파일", existing: source, expected: block + "\n" },
{ name: "innolive-client의 기존 규칙과 두 LFS 규칙", existing: source + clientLfs, expected: block + "\n" + clientLfs },
{ name: "innolive-ai의 기존 규칙과 LFS 규칙", existing: source + aiLfs, expected: block + "\n" + aiLfs },
{
name: "주석에서 마커를 언급하는 고유 내용",
existing: "# example: # framework-collaboration-harness:start\n" + aiLfs + "# example: # framework-collaboration-harness:end\n",
expected: "# example: # framework-collaboration-harness:start\n" + aiLfs + "# example: # framework-collaboration-harness:end\n" + block
},
{
name: "닫는 마커가 없는 기존 내용",
existing: "# framework-collaboration-harness:start\n" + aiLfs,
expected: "# framework-collaboration-harness:start\n" + aiLfs + block
},
{
name: "기존 규칙 사이의 주석·공백·CRLF·유사 규칙",
existing: "# custom \r\n- text=auto eol=lf\r\n" + aiLfs + " \r\n*.md text eol=lf linguist-detectable\r\n*.json text eol=crlf\n# tail ",
expected: "# custom \r\n" + block + aiLfs + " \r\n*.json text eol=crlf\n# tail "
},
{
name: "관리 블록 양쪽의 고유 내용과 동일한 규칙 보존",
existing: "# before\n- text=auto eol=lf\n\n" + staleBlock + clientLfs + "\n# tail ",
expected: "# before\n- text=auto eol=lf\n\n" + block + clientLfs + "\n# tail "
},
{
name: "CRLF 관리 블록 뒤 빈 줄 보존",
existing: staleBlock.replaceAll("\n", "\r\n") + "\r\n" + aiLfs,
expected: block + "\r\n" + aiLfs
}
];

for (const { name, existing, expected } of cases) {
test(`.gitattributes 로컬 적용 및 재실행: ${name}`, async (t) => {
const targetRoot = await mkdtemp(join(tmpdir(), "harness-attributes-"));
t.after(() => rm(targetRoot, { recursive: true, force: true }));
if (existing !== undefined) await writeFile(join(targetRoot, ".gitattributes"), existing);
const options = {
sourceRoot: process.cwd(),
targetRoot,
items: syncItems.filter((item) => item.destination === ".gitattributes")
};

await applyHarnessFiles(options);
assert.equal(await readFile(join(targetRoot, ".gitattributes"), "utf8"), expected);
await applyHarnessFiles(options);
assert.equal(await readFile(join(targetRoot, ".gitattributes"), "utf8"), expected);
});
}

test("원본 규칙이 바뀌어도 이전 하네스 규칙을 중복 없이 이전해요", () => {
const updatedSource = "*.md text eol=lf\n";
const expected = "# framework-collaboration-harness:start\n*.md text eol=lf\n# framework-collaboration-harness:end\n\n" + aiLfs;
const merged = mergeManagedAttributes(source + aiLfs, updatedSource);
assert.equal(merged, expected);
assert.equal(mergeManagedAttributes(merged, updatedSource), expected);
});
63 changes: 61 additions & 2 deletions tests/harness-sync-installation.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,72 @@
import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto";
import { createHash, generateKeyPairSync } from "node:crypto";
import test from "node:test";
import { createAppJwt, syncHarnessInstallation } from "../sync/github-app.ts";
import { createAppJwt, loadSyncTree, syncHarnessInstallation } from "../sync/github-app.ts";
import { mergeManagedInstructions } from "../sync/instructions.ts";
import { syncPullRequestBody } from "../sync/pr-body.ts";

function json(body, status = 200) {
return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body };
}

for (const scenario of ["missing", "legacy", "managed"]) {
test(`GitHub App .gitattributes 보존 및 변경 없는 재실행의 PR 미생성: ${scenario}`, async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const files = await loadSyncTree({ sourceRoot: process.cwd() });
const source = files.find((file) => file.path === ".gitattributes").content.toString("utf8");
const block = `# framework-collaboration-harness:start\n${source.trim()}\n# framework-collaboration-harness:end\n`;
const lfs = "*.pt filter=lfs diff=lfs merge=lfs -text\n";
const remote = new Map(files.filter((file) => file.path !== ".gitattributes").map((file) => [
file.path,
file.mode === "append-managed-instructions" ? mergeManagedInstructions("", file.content.toString("utf8")) : file.content.toString("utf8")
]));
if (scenario === "legacy") remote.set(".gitattributes", source + lfs);
if (scenario === "managed") remote.set(".gitattributes", lfs + "# framework-collaboration-harness:start\n*.old text\n# framework-collaboration-harness:end\n# custom\n");
const expected = scenario === "missing" ? block : scenario === "legacy" ? block + "\n" + lfs : lfs + block + "# custom\n";
const writes = [];
const blobSha = (content: string) => createHash("sha1").update(`blob ${Buffer.byteLength(content)}\0`).update(content).digest("hex");
const options = {
installationId: 10,
config: { appId: "Iv1.test", privateKey: privateKey.export({ type: "pkcs1", format: "pem" }), sourceRepository: "team-framework/framework-agent-harness-sync" },
sourceRoot: process.cwd(),
fetchImpl: async (url, request: any = {}) => {
const path = new URL(url).pathname + new URL(url).search;
if (path === "/app/installations/10/access_tokens") return json({ token: "installation-token" }, 201);
if (path === "/installation/repositories?per_page=100&page=1") return json({ repositories: [
{ full_name: "team-framework/target", name: "target", default_branch: "main" }
] });
if (request.method === "POST") writes.push(path);
if (path.startsWith("/repos/team-framework/target/pulls?")) return json([]);
if (path.endsWith("/git/ref/heads/main")) return json({ object: { sha: "base-commit" } });
if (path.endsWith("/git/commits/base-commit")) return json({ sha: "base-commit", tree: { sha: "base-tree" } });
if (path.endsWith("/git/trees/base-tree?recursive=1")) return json({ tree: [...remote].map(([path, content]) => ({ path, type: "blob", sha: blobSha(content) })) });
if (path.includes("/git/blobs/")) {
const content = [...remote.values()].find((content) => path.endsWith(`/git/blobs/${blobSha(content)}`));
assert.notEqual(content, undefined);
return json({ encoding: "base64", content: Buffer.from(content).toString("base64") });
}
if (path.endsWith("/git/trees") && request.method === "POST") {
const { tree } = JSON.parse(request.body);
assert.deepEqual(tree, [{ path: ".gitattributes", mode: "100644", type: "blob", content: expected }]);
// Model the resulting file already applied to main on the next sync.
for (const file of tree) remote.set(file.path, file.content);
return json({ sha: "sync-tree" }, 201);
}
if (path.endsWith("/git/commits") && request.method === "POST") return json({ sha: "sync-commit" }, 201);
if (path.endsWith("/git/refs") && request.method === "POST") return json({}, 201);
if (path.endsWith("/pulls") && request.method === "POST") return json({ html_url: "https://github.com/team-framework/target/pull/1" }, 201);
throw new Error(`예상하지 않은 GitHub 요청: ${request.method || "GET"} ${path}`);
}
};

assert.equal((await syncHarnessInstallation(options))[0].status, "pr_created");
assert.equal(writes.length, 4);
writes.length = 0;
assert.deepEqual(await syncHarnessInstallation(options), [{ repository: "team-framework/target", status: "no_changes" }]);
assert.deepEqual(writes, []);
});
}

test("설치된 위키와 MCP 저장소는 읽거나 쓰지 않고 다른 레포에 전체 스킬 Draft PR을 만들어요", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const calls = [];
Expand Down
Loading