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
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="patchloop:repo">` / `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`
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 をソースコードに対応付けるための情報。未指定のフィールドは `<meta name="patchloop:repo">` / `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`
Expand Down Expand Up @@ -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`
Expand Down
55 changes: 54 additions & 1 deletion dist/patchloop-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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; <meta name="patchloop:..."> tags
// fill any missing field.
sourceContext: null,
deliveryMode: "receiver",
slackWebhookUrl: "",
showDeliverySettings: false,
Expand All @@ -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 },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions server/receive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
37 changes: 37 additions & 0 deletions test/receiver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
84 changes: 84 additions & 0 deletions test/widget-source-context.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 12 additions & 1 deletion widget/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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; <meta name="patchloop:..."> tags
// fill any missing field.
sourceContext: null,
deliveryMode: "receiver",
slackWebhookUrl: "",
showDeliverySettings: false,
Expand All @@ -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 },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
Expand Down
37 changes: 37 additions & 0 deletions widget/src/source-context.js
Original file line number Diff line number Diff line change
@@ -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() : "";
}
Loading