standup-mr 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to standup-mr are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.1] - 2026-09-02
8
+
9
+ ### Fixed
10
+
11
+ - A note posted to Google Chat or Slack arrived with its formatting showing:
12
+ `**bold**` as literal asterisks, `## Dün` as a literal heading marker.
13
+ Neither renders standard Markdown — both use a single asterisk for bold and
14
+ have no headings — while the note is Markdown throughout. Bold is now
15
+ converted and headings become bold lines for those two. Discord speaks
16
+ Markdown natively and is sent untouched, and fenced blocks pass through
17
+ verbatim so a code sample keeps its asterisks.
18
+
19
+ ## [0.6.0] - 2026-09-02
20
+
21
+ ### Added
22
+
23
+ - **Google Chat webhooks.** `--google-chat URL` on the CLI, `kind:
24
+ "google-chat"` in `post_standup_note`, and `chat.googleapis.com` added to
25
+ host inference so neither is usually needed. Google Chat takes the same
26
+ `{"text"}` body as Slack, so this was only ever a naming gap — but telling
27
+ someone to pass `--slack` for a Google Chat webhook is a hack, not a
28
+ feature.
29
+
30
+ Anything else that accepts a Slack-shaped body — Mattermost, Rocket.Chat, an
31
+ n8n or Zapier endpoint — already works by passing the slack kind.
32
+
33
+ ### Changed
34
+
35
+ - `standup post` builds its flags and its error message from the list of
36
+ implemented channels instead of a hand-written pair, so adding a channel is
37
+ one map entry. The MCP tool's `kind` enum is now tested against that same
38
+ list: a channel can no longer reach the CLI and quietly miss MCP.
39
+
7
40
  ## [0.5.0] - 2026-09-02
8
41
 
9
42
  ### Added
package/README.md CHANGED
@@ -30,6 +30,10 @@ npx standup-mr fetch --markdown # structured digest
30
30
  npx standup-mr fetch --lang tr # Turkish date labels
31
31
  ```
32
32
 
33
+ ```bash
34
+ npx standup-mr fetch --markdown | npx standup-mr post --google-chat "$URL"
35
+ ```
36
+
33
37
  ### Identity
34
38
 
35
39
  | | GitHub | GitLab |
@@ -123,13 +127,23 @@ The server exposes three tools:
123
127
  |---|---|
124
128
  | `get_standup_data` | Reads the provider and returns the report as JSON. Optional `provider`, `host`, `lang`. |
125
129
  | `get_note_instructions` | Returns the note-writing rules, so the assistant can write the note the way the skill would. |
126
- | `post_standup_note` | Posts a finished note to a Slack or Discord webhook. |
130
+ | `post_standup_note` | Posts a finished note to a Slack, Discord or Google Chat webhook. |
127
131
 
128
132
  `post_standup_note` reads the webhook URL from `STANDUP_WEBHOOK_URL` and never
129
133
  takes it as an argument — anyone holding that URL can post to the channel, so
130
134
  it belongs with the tokens, not in a transcript. The payload shape is inferred
131
135
  from the URL host; `kind` is only needed when a proxy hides it.
132
136
 
137
+ Three shapes are implemented: `slack` and `google-chat` both post `{"text"}`,
138
+ `discord` posts `{"content"}`.
139
+
140
+ Slack and Google Chat do not render standard Markdown, so the note is rewritten
141
+ on the way out: `**bold**` becomes `*bold*` and a `##` heading becomes a bold
142
+ line. Inline code, fenced blocks and their contents are left alone. Discord
143
+ speaks Markdown natively and is sent untouched. Anything else that accepts a Slack-shaped body —
144
+ Mattermost, Rocket.Chat, an n8n or Zapier endpoint — works today by passing
145
+ `kind: "slack"`, or `--slack URL` on the CLI.
146
+
133
147
  Outside MCP, the same rules are available on stdout:
134
148
 
135
149
  ```bash
@@ -187,7 +187,32 @@ function ghToken(host) {
187
187
  }
188
188
 
189
189
  // src/notify/notify.constants.ts
190
- var PAYLOAD_FIELD = { slack: "text", discord: "content" };
190
+ var PAYLOAD_FIELD = {
191
+ slack: "text",
192
+ discord: "content",
193
+ "google-chat": "text"
194
+ };
195
+ var WEBHOOK_KINDS = Object.keys(PAYLOAD_FIELD);
196
+
197
+ // src/render/chat.ts
198
+ var MARKDOWN_NATIVE = ["discord"];
199
+ var FENCE = /^```/;
200
+ function convertLine(line) {
201
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
202
+ if (heading) return `*${heading[1]}*`;
203
+ return line.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, "*$1*");
204
+ }
205
+ function toChatText(markdown, kind) {
206
+ if (MARKDOWN_NATIVE.includes(kind)) return markdown;
207
+ let fenced = false;
208
+ return markdown.split("\n").map((line) => {
209
+ if (FENCE.test(line)) {
210
+ fenced = !fenced;
211
+ return line;
212
+ }
213
+ return fenced ? line : convertLine(line);
214
+ }).join("\n");
215
+ }
191
216
 
192
217
  // src/notify/notify.ts
193
218
  async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
@@ -200,7 +225,7 @@ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
200
225
  const response = await fetchImpl(url, {
201
226
  method: "POST",
202
227
  headers: { "Content-Type": "application/json" },
203
- body: JSON.stringify({ [field]: text })
228
+ body: JSON.stringify({ [field]: toChatText(text, kind) })
204
229
  });
205
230
  if (!response.ok) {
206
231
  throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
@@ -208,6 +233,7 @@ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
208
233
  }
209
234
  var WEBHOOK_HOSTS = {
210
235
  "hooks.slack.com": "slack",
236
+ "chat.googleapis.com": "google-chat",
211
237
  "discord.com": "discord",
212
238
  "discordapp.com": "discord",
213
239
  "ptb.discord.com": "discord",
@@ -1197,6 +1223,8 @@ export {
1197
1223
  glabToken,
1198
1224
  ghHosts,
1199
1225
  ghToken,
1226
+ WEBHOOK_KINDS,
1227
+ toChatText,
1200
1228
  postWebhook,
1201
1229
  inferWebhookKind,
1202
1230
  buildUrl,
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ConfigError,
4
+ WEBHOOK_KINDS,
4
5
  buildReport,
5
6
  connect,
6
7
  findPackageRoot,
7
8
  postWebhook,
8
9
  readStandupSkillBody,
9
10
  toMarkdown
10
- } from "./chunk-S2PFIUNI.js";
11
+ } from "./chunk-NJO6LFYN.js";
11
12
 
12
13
  // src/cli/cli.ts
13
14
  import { realpathSync } from "fs";
@@ -21,7 +22,7 @@ var USAGE = `standup \u2014 standup notes from merge request state
21
22
  Usage:
22
23
  standup fetch [--provider github|gitlab] [--host H] [--token T]
23
24
  [--lang en|tr] [--markdown]
24
- standup post (--slack URL | --discord URL) [--text TEXT]
25
+ standup post (--slack URL | --discord URL | --google-chat URL) [--text TEXT]
25
26
  standup mcp
26
27
  standup instructions
27
28
 
@@ -73,22 +74,27 @@ async function main(argv) {
73
74
  const { values } = parseArgs({
74
75
  args: rest,
75
76
  options: {
76
- slack: { type: "string" },
77
- discord: { type: "string" },
77
+ ...Object.fromEntries(
78
+ WEBHOOK_KINDS.map((kind2) => [kind2, { type: "string" }])
79
+ ),
78
80
  text: { type: "string", default: "-" }
79
81
  }
80
82
  });
81
- const url = values.slack ?? values.discord;
82
- if (!url) {
83
- process.stderr.write("Pass --slack URL or --discord URL.\n");
83
+ const urls = values;
84
+ const kind = WEBHOOK_KINDS.find((candidate) => urls[candidate]);
85
+ if (!kind) {
86
+ const flags = WEBHOOK_KINDS.map((candidate) => `--${candidate} URL`).join(" | ");
87
+ process.stderr.write(`Pass one of: ${flags}.
88
+ `);
84
89
  return 1;
85
90
  }
91
+ const url = urls[kind];
86
92
  const text = values.text === "-" ? await readStdin() : values.text;
87
93
  if (!text.trim()) {
88
94
  process.stderr.write("Nothing to post: empty text.\n");
89
95
  return 1;
90
96
  }
91
- await postWebhook(url, text, values.slack ? "slack" : "discord");
97
+ await postWebhook(url, text, kind);
92
98
  return 0;
93
99
  }
94
100
  if (command === "mcp") {
package/dist/index.d.ts CHANGED
@@ -125,6 +125,7 @@ declare function previousActiveDays(eventDates: Set<string>, today: Date): Array
125
125
  declare const PAYLOAD_FIELD: {
126
126
  readonly slack: "text";
127
127
  readonly discord: "content";
128
+ readonly 'google-chat': "text";
128
129
  };
129
130
 
130
131
  type WebhookKind = keyof typeof PAYLOAD_FIELD;
@@ -276,6 +277,8 @@ interface SelectOptions {
276
277
  declare function chooseKind(options?: SelectOptions): ProviderKind;
277
278
  declare function connect(options?: SelectOptions): Provider;
278
279
 
280
+ declare function toChatText(markdown: string, kind: WebhookKind): string;
281
+
279
282
  declare function toMarkdown(report: StandupReport, lang?: string): string;
280
283
 
281
284
  declare function buildReport(provider: Provider, today: Date, lang?: string, lookbackDays?: number): Promise<StandupReport>;
@@ -284,4 +287,4 @@ declare function readStandupSkillBody(): string;
284
287
 
285
288
  declare function extractErrors(rawTrace: string, limit?: number): string[];
286
289
 
287
- export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, inferWebhookKind, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, readStandupSkillBody, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
290
+ export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, inferWebhookKind, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, readStandupSkillBody, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toChatText, toMarkdown, undiagnosed, unreachable };
package/dist/index.js CHANGED
@@ -41,10 +41,11 @@ import {
41
41
  resolveHost,
42
42
  resolveToken,
43
43
  sendWithRetry,
44
+ toChatText,
44
45
  toMarkdown,
45
46
  undiagnosed,
46
47
  unreachable
47
- } from "./chunk-S2PFIUNI.js";
48
+ } from "./chunk-NJO6LFYN.js";
48
49
  export {
49
50
  ApiError,
50
51
  ConfigError,
@@ -87,6 +88,7 @@ export {
87
88
  resolveHost,
88
89
  resolveToken,
89
90
  sendWithRetry,
91
+ toChatText,
90
92
  toMarkdown,
91
93
  undiagnosed,
92
94
  unreachable
@@ -32,7 +32,32 @@ function packageVersion(moduleUrl) {
32
32
  }
33
33
 
34
34
  // src/notify/notify.constants.ts
35
- var PAYLOAD_FIELD = { slack: "text", discord: "content" };
35
+ var PAYLOAD_FIELD = {
36
+ slack: "text",
37
+ discord: "content",
38
+ "google-chat": "text"
39
+ };
40
+ var WEBHOOK_KINDS = Object.keys(PAYLOAD_FIELD);
41
+
42
+ // src/render/chat.ts
43
+ var MARKDOWN_NATIVE = ["discord"];
44
+ var FENCE = /^```/;
45
+ function convertLine(line) {
46
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
47
+ if (heading) return `*${heading[1]}*`;
48
+ return line.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, "*$1*");
49
+ }
50
+ function toChatText(markdown, kind) {
51
+ if (MARKDOWN_NATIVE.includes(kind)) return markdown;
52
+ let fenced = false;
53
+ return markdown.split("\n").map((line) => {
54
+ if (FENCE.test(line)) {
55
+ fenced = !fenced;
56
+ return line;
57
+ }
58
+ return fenced ? line : convertLine(line);
59
+ }).join("\n");
60
+ }
36
61
 
37
62
  // src/notify/notify.ts
38
63
  async function postWebhook(url, text2, kind = "slack", fetchImpl = fetch) {
@@ -45,7 +70,7 @@ async function postWebhook(url, text2, kind = "slack", fetchImpl = fetch) {
45
70
  const response = await fetchImpl(url, {
46
71
  method: "POST",
47
72
  headers: { "Content-Type": "application/json" },
48
- body: JSON.stringify({ [field]: text2 })
73
+ body: JSON.stringify({ [field]: toChatText(text2, kind) })
49
74
  });
50
75
  if (!response.ok) {
51
76
  throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
@@ -53,6 +78,7 @@ async function postWebhook(url, text2, kind = "slack", fetchImpl = fetch) {
53
78
  }
54
79
  var WEBHOOK_HOSTS = {
55
80
  "hooks.slack.com": "slack",
81
+ "chat.googleapis.com": "google-chat",
56
82
  "discord.com": "discord",
57
83
  "discordapp.com": "discord",
58
84
  "ptb.discord.com": "discord",
@@ -1117,13 +1143,13 @@ async function runStandupTool(args, collector = collect) {
1117
1143
  var WEBHOOK_URL_ENV = "STANDUP_WEBHOOK_URL";
1118
1144
  var POST_TOOL_DESCRIPTION = `Posts a finished standup note to a chat webhook. This one has a side effect: it sends a message other people will see, so only call it on a note the user has agreed to send.
1119
1145
 
1120
- The webhook URL is read from ${WEBHOOK_URL_ENV}, never from an argument \u2014 a webhook URL is a credential, since anyone holding it can post to the channel. Slack and Discord payload shapes are supported; the shape is inferred from the URL host, and \`kind\` is only needed for a proxied or self-hosted endpoint whose host gives nothing away. A missing URL, an unrecognised host, or a webhook that rejects the message comes back as an error rather than a silent success.`;
1146
+ The webhook URL is read from ${WEBHOOK_URL_ENV}, never from an argument \u2014 a webhook URL is a credential, since anyone holding it can post to the channel. Slack and Discord payload shapes are supported; the shape is inferred from the URL host, and \`kind\` is only needed for a proxied or self-hosted endpoint whose host gives nothing away \u2014 Mattermost and Rocket.Chat take the slack shape. A missing URL, an unrecognised host, or a webhook that rejects the message comes back as an error rather than a silent success.`;
1121
1147
  var POST_TOOL_SCHEMA = {
1122
1148
  text: z.string().min(1).describe(
1123
1149
  "The note to post, as the chat should render it. Slack and Discord both take Markdown, so send the written note rather than the raw JSON from get_standup_data."
1124
1150
  ),
1125
- kind: z.enum(["slack", "discord"]).optional().describe(
1126
- 'Which payload shape to send: slack posts {"text"}, discord posts {"content"}. Omit it \u2014 the shape is inferred from the webhook host. Pass it only when the host is not recognisable, e.g. a proxy in front of the real webhook.'
1151
+ kind: z.enum(["slack", "discord", "google-chat"]).optional().describe(
1152
+ 'Which payload shape to send: slack and google-chat post {"text"}, discord posts {"content"}. Omit it \u2014 the shape is inferred from the webhook host. Pass it only when the host is not recognisable, e.g. a proxy in front of the real webhook.'
1127
1153
  )
1128
1154
  };
1129
1155
  var INSTRUCTIONS_TOOL_DESCRIPTION = "Returns the note-writing rules \u2014 the playbook for turning get_standup_data output into a standup note a person would actually say out loud: how to group the previous day by theme, how to derive today from open merge requests, and how to report a blocker from its job log. Read-only, no arguments, no network. Call it once before writing the first note; the rules do not change between calls.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standup-mr",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "mcpName": "io.github.Jubstaaa/standup-mr",
5
5
  "description": "Standup notes from merge request state, not commit logs.",
6
6
  "keywords": [