From 11a45083a23e2bd9db24fe975e41e53c5a5812c1 Mon Sep 17 00:00:00 2001 From: kosako Date: Tue, 7 Jul 2026 09:19:11 +0900 Subject: [PATCH] =?UTF-8?q?widget/receiver:=20payload=20=E3=81=AB=20source?= =?UTF-8?q?Context(repo/branch/commit)=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#96=20PR-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI agent が feedback をソースコードに対応付けるための git 由来情報を payload に載せる。#96 のスライス PR-A(sourceContext の配線)。 issue body の agent-ready 再設計は PR-B で行う。 - widget: init オプション sourceContext({ repo, branch, commit, root, buildUrl, previewUrl })を正とし、 タグで フィールド単位に補完する resolveSourceContext を追加。init で一度だけ 解決し、buildPayload に載せる。PAYLOAD_SCHEMA_VERSION を 2 に bump - receiver: validateFeedbackPayload で optional な sourceContext を検証 (object であること・各フィールドが string であること)。store は JSON round-trip 設計のため変更不要 - README(日英): init オプションと payload 項目に sourceContext を追記 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LRdDjwxbAAaTMwwnzipkau --- README.en.md | 2 + README.md | 2 + dist/patchloop-widget.js | 55 ++++++++++++++++++- server/receive.js | 13 +++++ test/receiver.test.js | 37 +++++++++++++ test/widget-source-context.test.js | 84 ++++++++++++++++++++++++++++++ widget/src/index.js | 13 ++++- widget/src/source-context.js | 37 +++++++++++++ 8 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 test/widget-source-context.test.js create mode 100644 widget/src/source-context.js diff --git a/README.en.md b/README.en.md index 792746f..4493802 100644 --- a/README.en.md +++ b/README.en.md @@ -95,6 +95,7 @@ PatchLoop includes a standalone widget that can be embedded into a normal HTML p - `projectId` (string) — identifier carried in the payload - `demoId` (string) — identifier carried in the payload +- `sourceContext` (object, optional) — git provenance of the page under review: `{ repo, branch, commit, root, buildUrl, previewUrl }`, all optional strings. The embedding side injects real values at build/deploy time; this is what lets an AI agent map feedback back to source code. Fields left unset are filled from `` / `patchloop:branch` / `patchloop:commit` / `patchloop:root` / `patchloop:build-url` / `patchloop:preview-url` meta tags - `reviewer` (string, optional) — pre-fills the reviewer field in the comment form. When omitted, the widget restores a saved reviewer from `localStorage`; otherwise the field starts empty - `reviewerStorageKey` (string, optional) — `localStorage` key used to persist the reviewer name; defaults to `patchloop:reviewer` - `persistFeedback` (boolean, optional) — save the feedback list to `localStorage` and restore it after reloads on the same project / demo / page URL; defaults to `true` @@ -140,6 +141,7 @@ Main payload fields: - `reviewer` - `page.url` - `page.title` +- `sourceContext` — git provenance of the reviewed page (`repo` / `branch` / `commit` / `root` / `buildUrl` / `previewUrl`); `null` when neither the init option nor meta tags provide any field - `target.kind` - `target.x` - `target.y` diff --git a/README.md b/README.md index bd67856..6554bd7 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ PatchLoop は、普通の HTML に `script` tag で埋め込める standalone wi - `projectId` (string) — payload に乗せるプロジェクト識別子 - `demoId` (string) — payload に乗せるデモ識別子 +- `sourceContext` (object, optional) — レビュー対象ページの git 由来情報 `{ repo, branch, commit, root, buildUrl, previewUrl }`(すべて string・任意)。埋め込み側がビルド/デプロイ時に実値を注入するのが正で、AI agent が feedback をソースコードに対応付けるための情報。未指定のフィールドは `` / `patchloop:branch` / `patchloop:commit` / `patchloop:root` / `patchloop:build-url` / `patchloop:preview-url` の meta タグから補完されます - `reviewer` (string, optional) — コメントフォームに初期表示する投稿者名。未指定の場合は保存済み reviewer を `localStorage` から復元し、保存値もなければ空欄 - `reviewerStorageKey` (string, optional) — reviewer 名を保存する `localStorage` key。デフォルトは `patchloop:reviewer` - `persistFeedback` (boolean, optional) — feedback list を `localStorage` に保存し、同じ project / demo / page URL の reload 後に復元するか。デフォルトは `true` @@ -140,6 +141,7 @@ submit のたびに `document` で `patchloop:feedback` が発火し、`event.de - `reviewer` - `page.url` - `page.title` +- `sourceContext` — レビュー対象ページの git 由来情報(`repo` / `branch` / `commit` / `root` / `buildUrl` / `previewUrl`)。init オプションと meta タグのどちらにも無ければ `null` - `target.kind` - `target.x` - `target.y` diff --git a/dist/patchloop-widget.js b/dist/patchloop-widget.js index 5508c8f..92b5a35 100644 --- a/dist/patchloop-widget.js +++ b/dist/patchloop-widget.js @@ -320,6 +320,48 @@ function samePersistedPage(storedUrl, currentUrl) { return { normalizePageUrl, samePersistedPage }; })(); +// --- widget/src/source-context.js --- +const __pl_widget_src_source_context = (() => { +// Resolves the payload's sourceContext (#96): which repo/branch/commit the +// reviewed page was built from, so a coding agent can map feedback selectors +// back to source. The init option is the source of truth — the embedding side +// injects real values at build/deploy time. Meta tags are the fallback for +// hosts that can only stamp static HTML. The receiver's config is deliberately +// not a source: one receiver serves payloads from many previews, so a +// per-process value cannot be correct across projects. +const SOURCE_CONTEXT_FIELDS = [ + { key: "repo", metaName: "patchloop:repo" }, + { key: "branch", metaName: "patchloop:branch" }, + { key: "commit", metaName: "patchloop:commit" }, + { key: "root", metaName: "patchloop:root" }, + { key: "buildUrl", metaName: "patchloop:build-url" }, + { key: "previewUrl", metaName: "patchloop:preview-url" } +]; + +function resolveSourceContext(configured, doc) { + const options = configured && typeof configured === "object" ? configured : {}; + const context = {}; + for (const { key, metaName } of SOURCE_CONTEXT_FIELDS) { + const value = cleanValue(options[key]) || metaContent(doc, metaName); + if (value) context[key] = value; + } + return Object.keys(context).length > 0 ? context : null; +} + +function metaContent(doc, metaName) { + const node = doc.querySelector(`meta[name="${metaName}"]`); + return node ? cleanValue(node.content) : ""; +} + +// Only trimmed non-empty strings count; anything else (numbers, objects, a +// blank template placeholder like "" left unfilled) is treated as absent so +// the payload never carries junk values into stored records or issues. +function cleanValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +return { resolveSourceContext }; +})(); // --- shared/format.js --- const __pl_shared_format = (() => { // Formatting helpers shared by the widget (bundled into dist) and the @@ -386,6 +428,7 @@ const { pointAnchorOffsets, areaAnchorOffsets, roundedAnchor, geometryFromAnchor const { selectorFor, textFor } = __pl_widget_src_selector; const { freezeViewportUnits, flattenRulesForSnapshot } = __pl_widget_src_snapshot_css; const { samePersistedPage } = __pl_widget_src_url; +const { resolveSourceContext } = __pl_widget_src_source_context; const { truncateText, present, escapeHtml, escapeXml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = __pl_shared_format; const DEFAULTS = { @@ -396,6 +439,11 @@ const DEFAULTS = { // page, so it identifies the project and blocks indiscriminate spam rather // than acting as a secret. Empty = receiver runs with open ingest. ingestKey: "", + // Git provenance of the page under review (#96): { repo, branch, commit, + // root, buildUrl, previewUrl }, all optional strings. The embedding side + // injects real values at build/deploy time; tags + // fill any missing field. + sourceContext: null, deliveryMode: "receiver", slackWebhookUrl: "", showDeliverySettings: false, @@ -418,7 +466,8 @@ const FEEDBACK_STORAGE_VERSION = 1; // Version of the feedback payload schema itself (distinct from the storage // envelope and export bundle versions). Bump when the payload shape changes // so the receiver can branch on it as the schema grows for team use. -const PAYLOAD_SCHEMA_VERSION = 1; +// v2 adds the optional sourceContext block (#96). +const PAYLOAD_SCHEMA_VERSION = 2; const state = { options: { ...DEFAULTS }, @@ -450,6 +499,9 @@ function init(options = {}) { removeSelectionBox(); state.options = { ...DEFAULTS, ...options }; state.options.reviewer = initialReviewer(state.options); + // Resolved once here (option first, meta tags as fallback) so every payload + // built later carries the same provenance without re-reading the DOM. + state.options.sourceContext = resolveSourceContext(state.options.sourceContext, document); injectStyles(); renderShell(); bindGlobalCapture(); @@ -881,6 +933,7 @@ function buildPayload(comment, reviewer, target) { url: window.location.href, title: document.title }, + sourceContext: state.options.sourceContext, target: { kind: target.kind || "point", x: round(target.x), diff --git a/server/receive.js b/server/receive.js index c828b48..002cf43 100644 --- a/server/receive.js +++ b/server/receive.js @@ -1117,6 +1117,19 @@ function validateFeedbackPayload(payload) { if (payload.page.url != null) requireString(payload.page.url, "feedback.page.url"); if (payload.page.title != null) requireString(payload.page.title, "feedback.page.title"); + // Git provenance sent by the widget (#96). Optional because older widgets + // and hand-posted payloads do not carry it; when present, every field must + // be a string so downstream consumers (issue body, coding agents) can rely + // on the shape without re-validating. + if (payload.sourceContext != null) { + requirePlainObject(payload.sourceContext, "feedback.sourceContext"); + for (const field of ["repo", "branch", "commit", "root", "buildUrl", "previewUrl"]) { + if (payload.sourceContext[field] != null) { + requireString(payload.sourceContext[field], `feedback.sourceContext.${field}`); + } + } + } + if (!["point", "area"].includes(payload.target.kind)) { throw httpError("feedback.target.kind must be point or area", 400); } diff --git a/test/receiver.test.js b/test/receiver.test.js index 9c1a226..ccbbe59 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -59,6 +59,43 @@ test("POST /feedback rejects malformed feedback payloads", async (t) => { assert.deepEqual(await readStoredFeedback(receiver.dbPath), []); }); +test("POST /feedback stores the sourceContext block as sent (#96)", async (t) => { + const receiver = await startReceiver(t); + const payload = feedbackPayload("pl_source_context"); + payload.sourceContext = { + repo: "acme/shop", + branch: "feature/checkout", + commit: "abc1234", + root: "apps/web", + buildUrl: "https://ci.example/build/1", + previewUrl: "https://preview.example/pr-1" + }; + + const response = await postJson(`${receiver.baseUrl}/feedback`, payload); + + assert.equal(response.status, 201); + const stored = await readStoredFeedback(receiver.dbPath); + assert.deepEqual(stored[0].sourceContext, payload.sourceContext); +}); + +test("POST /feedback validates the sourceContext shape", async (t) => { + const receiver = await startReceiver(t); + + const notAnObject = feedbackPayload("pl_source_context_string"); + notAnObject.sourceContext = "acme/shop@abc1234"; + const objectResponse = await postJson(`${receiver.baseUrl}/feedback`, notAnObject); + assert.equal(objectResponse.status, 400); + assert.match(objectResponse.body.error, /feedback\.sourceContext must be an object/); + + const badField = feedbackPayload("pl_source_context_field"); + badField.sourceContext = { repo: "acme/shop", commit: 1234 }; + const fieldResponse = await postJson(`${receiver.baseUrl}/feedback`, badField); + assert.equal(fieldResponse.status, 400); + assert.match(fieldResponse.body.error, /feedback\.sourceContext\.commit must be a string/); + + assert.deepEqual(await readStoredFeedback(receiver.dbPath), []); +}); + test("POST /import stores bundle feedback and strips delivery metadata", async (t) => { const receiver = await startReceiver(t); const payload = { diff --git a/test/widget-source-context.test.js b/test/widget-source-context.test.js new file mode 100644 index 0000000..0e7e81f --- /dev/null +++ b/test/widget-source-context.test.js @@ -0,0 +1,84 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { resolveSourceContext } = require("../widget/src/source-context.js"); + +// Minimal document stand-in: resolveSourceContext only touches +// querySelector(`meta[name="..."]`) and the matched node's content. +function docWithMeta(metaByName = {}) { + return { + querySelector(selector) { + const match = /^meta\[name="([^"]+)"\]$/.exec(selector); + if (!match || !(match[1] in metaByName)) return null; + return { content: metaByName[match[1]] }; + } + }; +} + +test("resolveSourceContext takes known fields from the init option", () => { + const context = resolveSourceContext({ + repo: "acme/shop", + branch: "feature/checkout", + commit: "abc1234", + root: "apps/web", + buildUrl: "https://ci.example/build/1", + previewUrl: "https://preview.example/pr-1" + }, docWithMeta()); + + assert.deepEqual(context, { + repo: "acme/shop", + branch: "feature/checkout", + commit: "abc1234", + root: "apps/web", + buildUrl: "https://ci.example/build/1", + previewUrl: "https://preview.example/pr-1" + }); +}); + +test("resolveSourceContext drops unknown keys and non-string values", () => { + const context = resolveSourceContext({ + repo: "acme/shop", + commit: 1234, + extra: "nope", + branch: { name: "feature/checkout" } + }, docWithMeta()); + + assert.deepEqual(context, { repo: "acme/shop" }); +}); + +test("resolveSourceContext fills missing fields from meta tags per field", () => { + const doc = docWithMeta({ + "patchloop:repo": "meta/repo", + "patchloop:commit": "def5678", + "patchloop:build-url": "https://ci.example/build/2" + }); + + const context = resolveSourceContext({ repo: "acme/shop", branch: "main" }, doc); + + // The option wins where present; meta only fills the gaps. + assert.deepEqual(context, { + repo: "acme/shop", + branch: "main", + commit: "def5678", + buildUrl: "https://ci.example/build/2" + }); +}); + +test("resolveSourceContext treats blank strings as absent", () => { + const doc = docWithMeta({ "patchloop:commit": "def5678" }); + + // An unfilled template placeholder ("" or whitespace) must fall through to + // the meta tag instead of shipping an empty field. + const context = resolveSourceContext({ commit: " ", repo: "" }, doc); + + assert.deepEqual(context, { commit: "def5678" }); +}); + +test("resolveSourceContext returns null when nothing is configured", () => { + assert.equal(resolveSourceContext(null, docWithMeta()), null); + assert.equal(resolveSourceContext(undefined, docWithMeta()), null); + assert.equal(resolveSourceContext("acme/shop", docWithMeta()), null); + assert.equal(resolveSourceContext({ repo: "" }, docWithMeta({ "patchloop:repo": " " })), null); +}); diff --git a/widget/src/index.js b/widget/src/index.js index 3bdcd90..b084900 100644 --- a/widget/src/index.js +++ b/widget/src/index.js @@ -3,6 +3,7 @@ import { pointAnchorOffsets, areaAnchorOffsets, roundedAnchor, geometryFromAncho import { selectorFor, textFor } from "./selector.js"; import { freezeViewportUnits, flattenRulesForSnapshot } from "./snapshot-css.js"; import { samePersistedPage } from "./url.js"; +import { resolveSourceContext } from "./source-context.js"; import { truncateText, present, escapeHtml, escapeXml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } from "../../shared/format.js"; const DEFAULTS = { @@ -13,6 +14,11 @@ const DEFAULTS = { // page, so it identifies the project and blocks indiscriminate spam rather // than acting as a secret. Empty = receiver runs with open ingest. ingestKey: "", + // Git provenance of the page under review (#96): { repo, branch, commit, + // root, buildUrl, previewUrl }, all optional strings. The embedding side + // injects real values at build/deploy time; tags + // fill any missing field. + sourceContext: null, deliveryMode: "receiver", slackWebhookUrl: "", showDeliverySettings: false, @@ -35,7 +41,8 @@ const FEEDBACK_STORAGE_VERSION = 1; // Version of the feedback payload schema itself (distinct from the storage // envelope and export bundle versions). Bump when the payload shape changes // so the receiver can branch on it as the schema grows for team use. -const PAYLOAD_SCHEMA_VERSION = 1; +// v2 adds the optional sourceContext block (#96). +const PAYLOAD_SCHEMA_VERSION = 2; const state = { options: { ...DEFAULTS }, @@ -67,6 +74,9 @@ function init(options = {}) { removeSelectionBox(); state.options = { ...DEFAULTS, ...options }; state.options.reviewer = initialReviewer(state.options); + // Resolved once here (option first, meta tags as fallback) so every payload + // built later carries the same provenance without re-reading the DOM. + state.options.sourceContext = resolveSourceContext(state.options.sourceContext, document); injectStyles(); renderShell(); bindGlobalCapture(); @@ -498,6 +508,7 @@ function buildPayload(comment, reviewer, target) { url: window.location.href, title: document.title }, + sourceContext: state.options.sourceContext, target: { kind: target.kind || "point", x: round(target.x), diff --git a/widget/src/source-context.js b/widget/src/source-context.js new file mode 100644 index 0000000..76802ea --- /dev/null +++ b/widget/src/source-context.js @@ -0,0 +1,37 @@ +// Resolves the payload's sourceContext (#96): which repo/branch/commit the +// reviewed page was built from, so a coding agent can map feedback selectors +// back to source. The init option is the source of truth — the embedding side +// injects real values at build/deploy time. Meta tags are the fallback for +// hosts that can only stamp static HTML. The receiver's config is deliberately +// not a source: one receiver serves payloads from many previews, so a +// per-process value cannot be correct across projects. +const SOURCE_CONTEXT_FIELDS = [ + { key: "repo", metaName: "patchloop:repo" }, + { key: "branch", metaName: "patchloop:branch" }, + { key: "commit", metaName: "patchloop:commit" }, + { key: "root", metaName: "patchloop:root" }, + { key: "buildUrl", metaName: "patchloop:build-url" }, + { key: "previewUrl", metaName: "patchloop:preview-url" } +]; + +export function resolveSourceContext(configured, doc) { + const options = configured && typeof configured === "object" ? configured : {}; + const context = {}; + for (const { key, metaName } of SOURCE_CONTEXT_FIELDS) { + const value = cleanValue(options[key]) || metaContent(doc, metaName); + if (value) context[key] = value; + } + return Object.keys(context).length > 0 ? context : null; +} + +function metaContent(doc, metaName) { + const node = doc.querySelector(`meta[name="${metaName}"]`); + return node ? cleanValue(node.content) : ""; +} + +// Only trimmed non-empty strings count; anything else (numbers, objects, a +// blank template placeholder like "" left unfilled) is treated as absent so +// the payload never carries junk values into stored records or issues. +function cleanValue(value) { + return typeof value === "string" ? value.trim() : ""; +}