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() : "";
+}