diff --git a/AGENTS.md b/AGENTS.md index 32ecfae..01f7cd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,10 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te the locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true); `showEmoji=false` must strip every emoji from titles, descriptions, fields and links. - Milestone progress bars (🟢🟡🟠⬜) are data visualization and are exempt from the emoji toggle. +- Commit hashes, branches and tags render as inline code wrapped in a hyperlink + (`commitLink`/`branchLink`/`tagLink` helpers in `src/formatters/helpers.ts`, e.g. + ``[`abc123d`](https://.../commit/abc123def456)``, ``[`main`](https://.../tree/main)``), + falling back to plain inline code when the repo base URL is unavailable. - Locale templates use a `{emoji}` placeholder immediately followed by the text (no space); the formatter injects `em(...)` which carries the trailing space. diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts index 684237a..abb0032 100644 --- a/src/__tests__/formatter.test.ts +++ b/src/__tests__/formatter.test.ts @@ -167,9 +167,9 @@ describe("message title spec", () => { expect(msg.url).toBe("https://github.com/acme/widget/runs/2"); expect(msg.fields![0].value).toBe("✅ success"); expect(msg.fields![1].value).toBe("Cloudflare Pages"); - expect(msg.fields![2].value).toBe("main"); + expect(msg.fields![2].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)"); expect(msg.fields![3].value).toBe( - "[abc123d](https://github.com/acme/widget/commit/abc123def456)", + "[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)", ); }); @@ -191,8 +191,10 @@ describe("message title spec", () => { expect(msg.fields![0].value).toBe("❌ failure"); expect(msg.fields![1].value).toBe("test"); expect(msg.fields![2].value).toBe("CI"); - expect(msg.fields![3].value).toBe("`main`"); - expect(msg.fields![4].value).toBe("`abc123d`"); + expect(msg.fields![3].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)"); + expect(msg.fields![4].value).toBe( + "[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)", + ); }); it("status shows context, state and commit", () => { @@ -211,7 +213,9 @@ describe("message title spec", () => { expect(msg.title).toBe("acme/widget: continuous-integration/travis-ci — pending"); expect(msg.fields![0].value).toBe("⏳ pending"); expect(msg.fields![1].value).toBe("continuous-integration/travis-ci"); - expect(msg.fields![2].value).toBe("`abc123d`"); + expect(msg.fields![2].value).toBe( + "[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)", + ); expect(msg.fields![3].value).toBe("The Travis CI build is in progress"); }); @@ -227,8 +231,10 @@ describe("message title spec", () => { expect(msg.title).toBe("acme/widget: Deployment to `production` — created"); expect(msg.fields![0].value).toBe("🚀 created"); expect(msg.fields![1].value).toBe("production"); - expect(msg.fields![2].value).toBe("`main`"); - expect(msg.fields![3].value).toBe("`abc123d`"); + expect(msg.fields![2].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)"); + expect(msg.fields![3].value).toBe( + "[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)", + ); }); it("ping shows the webhook confirmation", () => { diff --git a/src/formatters/check.ts b/src/formatters/check.ts index ea5b7fd..3230f29 100644 --- a/src/formatters/check.ts +++ b/src/formatters/check.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors"; -import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers"; +import { branchLink, commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers"; export function formatCheckSuite( payload: Record, @@ -53,7 +53,7 @@ export function formatCheckSuite( if (suite.head_branch) { fields.push({ name: t("fields.branch"), - value: suite.head_branch, + value: branchLink(baseUrl, suite.head_branch), inline: true, }); } @@ -61,10 +61,7 @@ export function formatCheckSuite( if (suite.head_sha) { fields.push({ name: t("fields.commit"), - value: - baseUrl && suite.head_sha - ? `[${suite.head_sha.slice(0, 7)}](${baseUrl}/commit/${suite.head_sha})` - : `\`${suite.head_sha.slice(0, 7)}\``, + value: commitLink(baseUrl, suite.head_sha), inline: true, }); } @@ -99,6 +96,7 @@ export function formatStatus( const description = payload.description as string | undefined; const targetUrl = payload.target_url as string | undefined; const sha = payload.sha as string | undefined; + const baseUrl = repoBaseUrl(payload, repo); const emoji = state === "success" ? "✅" : state === "failure" || state === "error" ? "❌" : "⏳"; const em = (e: string): string => emojiPrefix(e, showEmoji); @@ -128,7 +126,7 @@ export function formatStatus( if (sha) { fields.push({ name: t("fields.commit"), - value: `\`${sha.slice(0, 7)}\``, + value: commitLink(baseUrl, sha), inline: true, }); } diff --git a/src/formatters/commit-comment.ts b/src/formatters/commit-comment.ts index fdd6913..ec8906f 100644 --- a/src/formatters/commit-comment.ts +++ b/src/formatters/commit-comment.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS } from "./colors"; -import { emojiPrefix, type T, buildMessage } from "./helpers"; +import { commitLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers"; export function formatCommitComment( payload: Record, @@ -20,6 +20,7 @@ export function formatCommitComment( const em = (e: string): string => emojiPrefix(e, showEmoji); const commentBody = comment.body?.slice(0, 500) ?? ""; const truncated = comment.body && comment.body.length > 500; + const baseUrl = repoBaseUrl(payload, repo); const shortSha = comment.commit_id?.slice(0, 7) ?? "???????"; const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -27,7 +28,7 @@ export function formatCommitComment( if (comment.commit_id) { fields.push({ name: t("fields.commit"), - value: `\`${shortSha}\``, + value: commitLink(baseUrl, comment.commit_id, shortSha), inline: true, }); } @@ -37,7 +38,7 @@ export function formatCommitComment( author, title: t("events.commit_comment.title", { repo: repo ?? t("common.repository"), - sha: shortSha, + sha: commitLink(baseUrl, comment.commit_id ?? "", shortSha), }), url: comment.html_url, color: GITHUB_COLORS.commit_comment, diff --git a/src/formatters/create.ts b/src/formatters/create.ts index e80f1f5..194b1ed 100644 --- a/src/formatters/create.ts +++ b/src/formatters/create.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS } from "./colors"; -import { emojiPrefix, type T, buildMessage } from "./helpers"; +import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers"; export function formatCreate( payload: Record, @@ -11,6 +11,8 @@ export function formatCreate( ): NeutralMessage { const refType = (payload.ref_type as string) ?? "branch"; const ref = (payload.ref as string) ?? t("common.unknown"); + const baseUrl = repoBaseUrl(payload, repo); + const refText = refType === "tag" ? tagLink(baseUrl, ref) : branchLink(baseUrl, ref); const emoji = refType === "tag" ? "🏷️" : "🌿"; const em = (e: string): string => emojiPrefix(e, showEmoji); @@ -25,7 +27,7 @@ export function formatCreate( fields.push({ name: t("fields.name"), - value: `\`${ref}\``, + value: refText, inline: true, }); @@ -44,7 +46,7 @@ export function formatCreate( repo: repo ?? t("common.repository"), emoji: em(emoji), type: refType, - ref, + ref: refText, }), color: GITHUB_COLORS.create, fields, @@ -63,6 +65,8 @@ export function formatDelete( ): NeutralMessage { const refType = (payload.ref_type as string) ?? "branch"; const ref = (payload.ref as string) ?? t("common.unknown"); + const baseUrl = repoBaseUrl(payload, repo); + const refText = refType === "tag" ? tagLink(baseUrl, ref) : branchLink(baseUrl, ref); const emoji = refType === "tag" ? "🏷️" : "🌿"; const em = (e: string): string => emojiPrefix(e, showEmoji); @@ -74,7 +78,7 @@ export function formatDelete( repo: repo ?? t("common.repository"), emoji: em(emoji), type: refType, - ref, + ref: refText, }), color: GITHUB_COLORS.delete, }, diff --git a/src/formatters/deployment.ts b/src/formatters/deployment.ts index ce419f3..e5ef4a8 100644 --- a/src/formatters/deployment.ts +++ b/src/formatters/deployment.ts @@ -1,6 +1,14 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS } from "./colors"; -import { emojiPrefix, type T, buildMessage } from "./helpers"; +import { + branchLink, + commitLink, + emojiPrefix, + tagLink, + type T, + buildMessage, + repoBaseUrl, +} from "./helpers"; export function formatDeployment( payload: Record, @@ -22,6 +30,7 @@ export function formatDeployment( const em = (e: string): string => emojiPrefix(e, showEmoji); const env = deployment.environment ?? t("common.unknown"); const shortSha = deployment.sha?.slice(0, 7) ?? "???????"; + const baseUrl = repoBaseUrl(payload, repo); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; fields.push({ @@ -37,9 +46,10 @@ export function formatDeployment( }); if (deployment.ref) { + const isTag = deployment.ref.startsWith("refs/tags/"); fields.push({ name: t("fields.branch_tag"), - value: `\`${deployment.ref.replace("refs/heads/", "")}\``, + value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref), inline: true, }); } @@ -47,7 +57,7 @@ export function formatDeployment( if (deployment.sha) { fields.push({ name: t("fields.commit"), - value: `\`${shortSha}\``, + value: commitLink(baseUrl, deployment.sha, shortSha), inline: true, }); } @@ -107,6 +117,7 @@ export function formatDeploymentStatus( const em = (e: string): string => emojiPrefix(e, showEmoji); const env = status.environment ?? deployment.environment ?? t("common.unknown"); const shortSha = deployment.sha?.slice(0, 7) ?? "???????"; + const baseUrl = repoBaseUrl(payload, repo); const fields: Array<{ name: string; value: string; inline?: boolean }> = []; @@ -123,9 +134,10 @@ export function formatDeploymentStatus( }); if (deployment.ref) { + const isTag = deployment.ref.startsWith("refs/tags/"); fields.push({ name: t("fields.branch_tag"), - value: `\`${deployment.ref}\``, + value: isTag ? tagLink(baseUrl, deployment.ref) : branchLink(baseUrl, deployment.ref), inline: true, }); } @@ -133,7 +145,7 @@ export function formatDeploymentStatus( if (deployment.sha) { fields.push({ name: t("fields.commit"), - value: `\`${shortSha}\``, + value: commitLink(baseUrl, deployment.sha, shortSha), inline: true, }); } diff --git a/src/formatters/helpers.ts b/src/formatters/helpers.ts index a5192eb..3831001 100644 --- a/src/formatters/helpers.ts +++ b/src/formatters/helpers.ts @@ -34,3 +34,32 @@ export function repoBaseUrl(payload: Record, repo?: string): st if (html) return html; return repo ? `https://github.com/${repo}` : undefined; } + +function encodeRefPath(ref: string): string { + return ref + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); +} + +/** Inline code + hyperlink for a commit, e.g. [`abc123d`](.../commit/abc123def456). */ +export function commitLink(baseUrl: string | undefined, sha: string, short?: string): string { + const label = short ?? sha.slice(0, 7); + return baseUrl ? `[\`${label}\`](${baseUrl}/commit/${encodeRefPath(sha)})` : `\`${label}\``; +} + +/** Inline code + hyperlink for a branch (or bare ref), e.g. [`main`](.../tree/main). */ +export function branchLink(baseUrl: string | undefined, branch: string, label?: string): string { + const clean = branch.replace("refs/heads/", "").replace("refs/tags/", ""); + const display = label ?? clean; + return baseUrl ? `[\`${display}\`](${baseUrl}/tree/${encodeRefPath(clean)})` : `\`${display}\``; +} + +/** Inline code + hyperlink for a tag, e.g. [`v1.0`](.../releases/tag/v1.0). */ +export function tagLink(baseUrl: string | undefined, tag: string, label?: string): string { + const clean = tag.replace("refs/tags/", ""); + const display = label ?? clean; + return baseUrl + ? `[\`${display}\`](${baseUrl}/releases/tag/${encodeRefPath(clean)})` + : `\`${display}\``; +} diff --git a/src/formatters/pull-request.ts b/src/formatters/pull-request.ts index 6a6127a..6862ddd 100644 --- a/src/formatters/pull-request.ts +++ b/src/formatters/pull-request.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types"; import { GITHUB_COLORS } from "./colors"; -import { emojiPrefix, type T, buildMessage } from "./helpers"; +import { branchLink, emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers"; export function formatPullRequest( payload: Record, @@ -17,8 +17,8 @@ export function formatPullRequest( state?: string; draft?: boolean; merged?: boolean; - head?: { ref?: string; sha?: string }; - base?: { ref?: string }; + head?: { ref?: string; sha?: string; repo?: { html_url?: string } }; + base?: { ref?: string; repo?: { html_url?: string } }; body?: string; labels?: Array<{ name?: string; color?: string }>; changed_files?: number; @@ -57,9 +57,12 @@ export function formatPullRequest( const fields: Array<{ name: string; value: string; inline?: boolean }> = []; if (pr.head?.ref && pr.base?.ref) { + const baseUrl = repoBaseUrl(payload, repo); + const headUrl = pr.head.repo?.html_url ?? baseUrl; + const baseRepoUrl = pr.base.repo?.html_url ?? baseUrl; fields.push({ name: t("fields.branch"), - value: `\`${pr.head.ref}\` → \`${pr.base.ref}\``, + value: `${branchLink(headUrl, pr.head.ref)} → ${branchLink(baseRepoUrl, pr.base.ref)}`, inline: true, }); } diff --git a/src/formatters/push.ts b/src/formatters/push.ts index 865ab26..f1d829d 100644 --- a/src/formatters/push.ts +++ b/src/formatters/push.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS } from "./colors"; -import { emojiPrefix, type T, buildMessage, repoBaseUrl } from "./helpers"; +import { branchLink, emojiPrefix, tagLink, type T, buildMessage, repoBaseUrl } from "./helpers"; export function formatPush( payload: Record, @@ -9,7 +9,9 @@ export function formatPush( t: T, showEmoji: boolean, ): NeutralMessage { - const ref = (payload.ref as string)?.replace("refs/heads/", "").replace("refs/tags/", "tag: "); + const rawRef = (payload.ref as string) ?? ""; + const isTagPush = rawRef.startsWith("refs/tags/"); + const ref = rawRef.replace("refs/heads/", "").replace("refs/tags/", "tag: "); const commits = (payload.commits ?? []) as Array<{ id?: string; message?: string; @@ -35,7 +37,11 @@ export function formatPush( } descriptionParts.push( - t("events.push.commits_pushed", { count, s: count !== 1 ? "s" : "", ref: ref ?? "" }), + t("events.push.commits_pushed", { + count, + s: count !== 1 ? "s" : "", + ref: isTagPush ? tagLink(baseUrl, rawRef, ref) : branchLink(baseUrl, rawRef, ref), + }), ); if (compareUrl) { diff --git a/src/formatters/workflow.ts b/src/formatters/workflow.ts index f438527..d8fea43 100644 --- a/src/formatters/workflow.ts +++ b/src/formatters/workflow.ts @@ -1,6 +1,6 @@ import type { NeutralMessage, NeutralAuthor } from "../types"; import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors"; -import { emojiPrefix, type T, buildMessage } from "./helpers"; +import { emojiPrefix, type T, buildMessage, branchLink, commitLink, repoBaseUrl } from "./helpers"; export function formatWorkflowJob( payload: Record, @@ -28,6 +28,7 @@ export function formatWorkflowJob( : (job.conclusion ?? "pending"); const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; const em = (e: string): string => emojiPrefix(e, showEmoji); + const baseUrl = repoBaseUrl(payload, repo); const colorKey = status === "success" ? "workflow_run_success" @@ -62,7 +63,7 @@ export function formatWorkflowJob( if (job.head_branch) { fields.push({ name: t("fields.branch"), - value: `\`${job.head_branch}\``, + value: branchLink(baseUrl, job.head_branch), inline: true, }); } @@ -70,7 +71,7 @@ export function formatWorkflowJob( if (job.head_sha) { fields.push({ name: t("fields.commit"), - value: `\`${job.head_sha.slice(0, 7)}\``, + value: commitLink(baseUrl, job.head_sha), inline: true, }); } @@ -121,6 +122,7 @@ export function formatWorkflowRun( : (workflow.conclusion ?? "pending"); const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳"; const em = (e: string): string => emojiPrefix(e, showEmoji); + const baseUrl = repoBaseUrl(payload, repo); const colorKey = status === "success" ? "workflow_run_success" @@ -150,7 +152,7 @@ export function formatWorkflowRun( if (workflow.head_branch) { fields.push({ name: t("fields.branch"), - value: `\`${workflow.head_branch}\``, + value: branchLink(baseUrl, workflow.head_branch), inline: true, }); } diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 5e0943f..5c0f082 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -88,7 +88,7 @@ export const en = { push: { force_push: "**Force push**", branch_created: "Branch created", - commits_pushed: "**{count}** commit{s} pushed to `{ref}`", + commits_pushed: "**{count}** commit{s} pushed to {ref}", view_comparison: "[View comparison]({url})", added: "+{count} added", removed: "-{count} removed", @@ -124,10 +124,10 @@ export const en = { title: "{repo}: {name}", }, create: { - title: "{repo}: {emoji}Created {type} `{ref}`", + title: "{repo}: {emoji}Created {type} {ref}", }, delete: { - title: "{repo}: {emoji}Deleted {type} `{ref}`", + title: "{repo}: {emoji}Deleted {type} {ref}", }, star: { starred: "Starred", @@ -154,7 +154,7 @@ export const en = { }, commit_comment: { action_comment: "{emoji}**{action}**", - title: "{repo}: Comment on commit `{sha}`", + title: "{repo}: Comment on commit {sha}", }, deployment: { title: "{repo}: Deployment to `{env}` — {state}", diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index b306c3c..83e7c95 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -88,7 +88,7 @@ export const zh = { push: { force_push: "**强制推送**", branch_created: "分支已创建", - commits_pushed: "**{count}** 个提交已推送到 `{ref}`", + commits_pushed: "**{count}** 个提交已推送到 {ref}", view_comparison: "[查看比较]({url})", added: "+{count} 新增", removed: "-{count} 删除", @@ -124,10 +124,10 @@ export const zh = { title: "{repo}: {name}", }, create: { - title: "{repo}: {emoji}已创建{type} `{ref}`", + title: "{repo}: {emoji}已创建{type} {ref}", }, delete: { - title: "{repo}: {emoji}已删除{type} `{ref}`", + title: "{repo}: {emoji}已删除{type} {ref}", }, star: { starred: "已加星标", @@ -154,7 +154,7 @@ export const zh = { }, commit_comment: { action_comment: "{emoji}**{action}**", - title: "{repo}: 提交 `{sha}` 的评论", + title: "{repo}: 提交 {sha} 的评论", }, deployment: { title: "{repo}: 部署到 `{env}` — {state}",