@chaimp-org/feedback-ado

中文文档
0.9.0发布于 2026/11/06TypeScriptES Module近 30 天下载 3650

ChAImp SaaS Universal Feedback Engine — file beta feedback as Azure DevOps work items via Vercel OIDC → Entra federation (no secret).

原始使用文档
文档目录8 节
  1. Why
  2. Install
  3. Usage
  4. Through ATFS first (0.5.0)
  5. Configuration (environment)
  6. Preferred auth — Vercel OIDC federation (secretless)
  7. Surfacing (product vs stage)
  8. API

@chaimp-org/feedback-ado

The ChAImp SaaS Universal Feedback Engine — one implementation every product installs to turn a user's beta feedback into an Azure DevOps work item, authenticated by the product's Entra app registration over Vercel OIDC federation (no secret stored anywhere).

Why

Every product had (or needed) its own copy of "feedback → tracker". This is the single, shared, verified implementation: Bug/User-Story split, Markdown bodies, tags, priority-support routing, and a resilient best-effort call that never throws (the product DB row stays the source of truth).

Install

pnpm add @chaimp-org/feedback-ado

Usage

In a server route, build the work item from normalised feedback and file it under waitUntil (a bare fire-and-forget promise is frozen when the serverless response returns, before the ADO create can finish):

import { waitUntil } from "@vercel/functions";
import { adoApiEnabled, buildFeedbackWorkItem, createAdoWorkItem } from "@chaimp-org/feedback-ado";

// ... after persisting the feedback row (source of truth) ...
if (adoApiEnabled()) {
  waitUntil(
    createAdoWorkItem(
      buildFeedbackWorkItem({
        category: parsed.category,        // "bug" → Bug, else User Story
        message: parsed.message,
        orgName: ctx.org.name,
        orgSlug: ctx.org.slug,
        userEmail: ctx.userEmail,
        plan: ctx.org.plan,
        page: parsed.page,
        priority: isPro,                  // ⚡ + Priority 1 + priority-support tag
        envLabel: process.env.NEXT_PUBLIC_ENV_LABEL,
      }),
    ).catch(() => {}),
  );
}

buildFeedbackWorkItem is optional — call createAdoWorkItem(item) directly if you want to shape the work item yourself.

Through ATFS first (0.5.0)

submitFeedback sends the feedback to the portal's ATFS intake (POST /api/atfs/intake). The portal triages it and files it. When the portal definitely did not take the feedback, submitFeedback files it directly in ADO, as in 0.4. It never throws.

import { waitUntil } from "@vercel/functions";
import { submitFeedback } from "@chaimp-org/feedback-ado";

waitUntil(
  submitFeedback(
    {
      category: parsed.category,
      message: parsed.message,
      product: "cartoncrew",
      orgId: ctx.org.id,            // the product's org id (the portal's tenant external_ref)
      orgName: ctx.org.name,
      orgSlug: ctx.org.slug,
      userEmail: ctx.userEmail,
      page: parsed.page,
    },
    { idempotencyKey: feedbackRow.id }, // your stored row id: a re-submit is then safe too
  ).then((r) => {
    if (!r.ok) console.warn("[feedback]", r.via, r.reason); // r.reason never holds a token
  }),
);

Results:

viaokMeaning
atfstrueThe portal stored it. Nothing was filed directly
adotrueFiled directly. reason says why (portal not configured, ATFS off, unreachable, refused)
adofalseThe portal did not take it and the direct filing failed too
uncertainfalseThe portal may have stored it (a lost answer) and no retry settled it. Nothing was filed directly, so it cannot be filed twice. Keep your row and submit again later with the same idempotencyKey

The no-double-filing rule. Direct filing happens only when the portal definitely did not store the submission. That means one of these happened:

  • the portal is not configured;
  • the portal answered accepted: false;
  • before any unknown outcome, the connection never opened or the portal returned a 4xx.

A 503 is a refusal only when its body is the intake's own JSON refusal (accepted: false); a bare 503 from the platform or a proxy is unknown (0.7.0). A timeout after the request went out is unknown, and so are a reset and a 500, 502 or 504. An unknown outcome is retried with the same idempotency key. The portal keeps one item per key per product, so the retry returns the stored item. Once any attempt was unknown, only an explicit answer from the portal settles the outcome. Direct fallback filings are tagged atfs-fallback.

Direct filing is idempotent (0.7.0). A direct filing carries the tag fbk-<hash> (idemKeyTag(key): the first 32 hex characters of the SHA-256 of the idempotency key). Before it creates, it looks for an item with that tag by WIQL. If one exists, that id is returned with duplicate: true and nothing is filed. If the lookup fails, nothing is filed and the result is ok: false; submit again later with the same key.

Options: idempotencyKey (default: a random UUID), portal (overrides the env; pass null to skip the portal), retries (default 2), timeoutMs (default 15000, above undici's 10 s connect timeout), retryDelayMs (default 500), surface, plus every createAdoWorkItemId option for the fallback.

Configuration (environment)

Same variable names across every product; set them in the product's Vercel project (and .env.secrets.local for the local PAT fallback):

VarPurpose
ADO_ORGorganisation, e.g. chaimporg
ADO_PROJECTproject, e.g. One
ADO_AREA_PATHrequired per product, e.g. One\SaaS\<slug> (omitted → project default)
ADO_TENANT_IDEntra directory (tenant) id — preferred auth
ADO_CLIENT_IDEntra application (client) id — preferred auth
ADO_PATlocal-dev fallback only; used when ADO_TENANT_ID/ADO_CLIENT_ID are unset
ATFS_INTAKE_URL0.5.0: the portal base URL, https (for example https://portal.chaimp.org). Unset means direct filing only
ATFS_INTAKE_TOKEN0.5.0: this product's intake token. The portal holds the same value as ATFS_INTAKE_TOKEN_<SLUG>. It is a secret, so keep it in Vercel env only

Preferred auth — Vercel OIDC federation (secretless)

  1. Enable OIDC Federation on the Vercel project.
  2. On the app registration, add a federated credential: issuer https://oidc.vercel.com/<team>, subject owner:<team>:project:<vercel-project>:environment:<env>, audience api://AzureADTokenExchange.
  3. Add the service principal to the ADO org with Contribute on the area path.

The package exchanges the injected VERCEL_OIDC_TOKEN for an Azure DevOps access token (resource 499b84ac-…/.default) via @azure/identity's ClientAssertionCredential. No secret is stored.

Surfacing (product vs stage)

The engine and payload are shared; how feedback is surfaced in the UI is the only thing that varies between products (copy/framing, categories, placement), so it is configuration, not code. Declare a FeedbackSurface per product:

const surface: FeedbackSurface = {
  product: "knowledgeretention",
  stage: "beta",                       // "ga" once it graduates — no code change
  title: "Beta feedback",              // GA products: "Send feedback" / "Help & feedback"
  categories: [
    { value: "bug", label: "Report a bug", isBug: true },
    { value: "idea", label: "Suggest something" },
    { value: "general", label: "General" },
  ],
  placement: "beta-badge",             // GA: "help-menu" | "footer" | "nav"
};

createAdoWorkItem(buildFeedbackWorkItem({ category, message, /* … */ }, surface));

product + stage flow into the work item's tags (beta/ga, product slug) and body, so triage distinguishes a KR beta bug from a CartonCrew GA bug (on top of the area path). "Beta" is not special-cased — it is stage.

API

  • submitFeedback(input, opts?): Promise<SubmitFeedbackResult> (0.5.0) sends the feedback to the portal's ATFS intake first. It falls back to direct filing only when the portal definitely did not store it (see above).
  • idemKeyTag(key): string (0.7.0) — the ADO tag that carries an idempotency key.
  • atfsIntakeConfig(): AtfsPortalConfig | null (0.5.0) reads ATFS_INTAKE_URL + ATFS_INTAKE_TOKEN.
  • adoApiEnabled(): boolean — is enough config present to file (else skip; DB is source of truth).
  • createAdoWorkItem(item): Promise<boolean> — file one work item; never throws.
  • createAdoWorkItemId(item, opts?): Promise<number | null> — same, but returns the new work-item id (null on any failure). opts.idempotencyKey (0.7.0) makes it idempotent: an item already tagged idemKeyTag(key) is returned instead of filing a second one. opts.identity ({ tenantId, clientId }) authenticates as a different app registration (e.g. a least-privilege filer), opts.parentId adds the parent as a Hierarchy-Reverse relation (never as a field), opts.areaPath overrides ADO_AREA_PATH, opts.iterationPath sets System.IterationPath, opts.assignedTo sets System.AssignedTo (a display name), opts.onFailure(reason) is told a short secret-free reason when nothing was filed, opts.fetchImpl injects the transport for tests. All options are optional; omitting them keeps the earlier behaviour. Since 0.6.0 a Bug gets the Markdown body in both Repro Steps and System.Description (the board requires a Description on every item but a Test Case); a User Story is unchanged.
  • buildFeedbackWorkItem(input, surface?): WorkItemInput — the shared house style (title, Markdown body, tags, priority); the optional surface drives Bug/Story mapping and carries product + stage. Since 0.8.0 the body opens with the heading **Feedback** (the sender's own words, as typed; not verified): and the message as a blockquote (every line prefixed > , every line-break form made a newline, < escaped), then --- and the metadata list. A typed - **Env:** production line stays inside the quote, so the only unquoted metadata lines are the package's own. Each metadata value is kept on one line with its Markdown characters escaped. The title is the plain first non-blank line of the message, never the quoted body. The optional title input (plain text, scrubbed by the product, never escaped; control characters and line breaks become spaces; at most 120 characters) gives [category] slug: <title> instead, and is sent to the ATFS intake as subject, the Triage subject. Deploy order: the portal must run 0.166.0 or later before a product sends title; an older portal refuses the unknown subject (400), and submitFeedback then files the item directly in ADO (atfs-fallback), so it is never lost or filed twice, but it skips ATFS triage. Since 0.9.0 (AB#16958) every quoted line is also fully escaped, so the message renders literally: & and < become entities, the Markdown characters (\ ` * _ [ ] ( ) ! > ~ | # $ @) are backslash-escaped, a . or : inside a URL, domain or email address is escaped so nothing auto-links, and a list, heading or setext marker at the line start is escaped. No typed link, image, emphasis, code, heading, list, strikethrough, entity or HTML renders, and nothing loads from a URL the sender chose. Metadata values also escape & and ~. Send plain text: text a product already escaped (\-, &lt;) now shows its backslashes and entities. The message-derived title keeps at most 70 code points, so an emoji at the cut is never split. The ATFS intake path is unchanged: it receives the message as plain text.
  • quoteUntrusted(text): string — the escaped blockquote used above, for products that build their own body (0.8.0; fully escaped since 0.9.0).
  • resolveType(category, surface?): WorkItemType — Bug vs User Story for a category (uses the surface's categories/isBug; defaults to category === "bug").
  • Types: FeedbackSurface, FeedbackCategory, FeedbackStage, FeedbackInput, WorkItemInput, WorkItemType.

版本与发布记录

@chaimp-org/feedback-ado 共发布 6 个版本,首次发布于 2026/07/22;过去 12 个月发布了 6 个版本。

版本发布日期距今
0.9.0latest2026/11/063 天前
0.8.02026/11/027 天前
0.7.02026/11/027 天前
0.6.02026/11/027 天前
0.5.02026/11/018 天前
0.2.12026/07/223 个月前

关于 @chaimp-org/feedback-ado 的常见问题

@chaimp-org/feedback-ado 是什么?

ChAImp SaaS Universal Feedback Engine — file beta feedback as Azure DevOps work items via Vercel OIDC → Entra federation (no secret).

如何安装 @chaimp-org/feedback-ado?

在项目目录运行 npm install @chaimp-org/feedback-ado,或使用 pnpm add / yarn add / bun add。

@chaimp-org/feedback-ado 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@chaimp-org/feedback-ado 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@chaimp-org/feedback-ado 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@chaimp-org/feedback-ado 可以商用吗?

@chaimp-org/feedback-ado 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@chaimp-org/feedback-ado 的下载量有多少?

根据 npm 官方下载统计,@chaimp-org/feedback-ado 在 2026/10/10 – 2026/11/08 这 30 天内被下载 3,650 次,日均约 122 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@chaimp-org/feedback-ado 还在维护吗?

最新版本 0.9.0 发布于 2026/11/06(3 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/07/22。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。