Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"track_id": "improve-github-comment-20260329",
"type": "feature",
"status": "in_progress",
"created_at": "2026-03-29T16:43:22+09:00",
"updated_at": "2026-03-29T16:55:00+09:00",
"issue": "#319",
"pr": "",
"project": ""
}
96 changes: 96 additions & 0 deletions .please/docs/tracks/active/improve-github-comment-20260329/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Plan: Improve GitHub Deployment Comments

## Overview

- **Source**: [spec.md](./spec.md)
- **Issue**: #319
- **Created**: 2026-03-29
- **Approach**: Pragmatic

## Purpose

After this change, users of vercel-action will see rich, well-structured HTML table deployment comments on their PRs and commits — showing project name, status, preview URL, commit SHA, alias domains, and inspect links at a glance. They can verify it works by deploying with `github-comment: true` and checking the PR comment format.

## Context

The current default comment template is plain text with minimal formatting: a simple "✅ Preview" line followed by the URL. Competing actions like Cloudflare Pages and Vercel's own bot produce rich HTML table comments with structured rows for each piece of deployment info, making them far more scannable and professional. The goal is to match that quality while preserving backward compatibility for users with custom templates.

Key constraints: the `github-comment` input accepts `true`, `false`, or a custom string template. When set to a custom string, the existing template variable substitution must continue working unchanged. The new HTML table format only applies when `github-comment: true`.

The comment prefix (`Deploy preview for _name_ ready!`) is used to find and update existing comments. This prefix must be updated to match the new format while ensuring that old comments on existing PRs can still be detected during the transition period.

Non-goals: markdown table format, configurable style toggle, custom branding images.

## Architecture Decision

The approach modifies the existing `buildCommentBody()` function in `utils.ts` to produce an HTML table when `githubComment === true`, while keeping the custom string path untouched. A new `buildHtmlTableComment()` function handles the HTML generation. The comment prefix is updated but old prefixes are also checked during comment detection for backward compatibility.

The inspect URL is derived from the deployment URL by constructing `https://vercel.com/{org}/{project}/{deployment-id}` pattern, or from the Vercel API response when available. This data flows through the existing function signatures with a new optional `inspectUrl` parameter.

## Tasks

- [x] T001 Add `inspectUrl` to types and config flow (file: src/types.ts)
- [x] T002 Build HTML table comment generator (file: src/utils.ts, depends on T001)
- [x] T003 Update comment prefix and detection logic (file: src/utils.ts, depends on T002)
- [x] T004 Wire inspect URL through deployment flow (file: src/index.ts, depends on T001)
- [x] T005 Update comment functions to pass new data (file: src/github-comments.ts, depends on T002, T003, T004)
- [x] T006 Update unit tests for HTML table comments (file: src/__tests__/utils.test.ts, depends on T002, T003)
- [x] T007 Update unit tests for comment functions (file: src/__tests__/github-comments.test.ts, depends on T005)
- [x] T008 [P] Update integration tests (file: src/__integration__/github-pr-comments.test.ts, depends on T005)

## Progress

- [x] (2026-03-29 17:30 KST) T001-T008 All tasks implemented in single pass
Evidence: `npx vitest run --project unit` → 117 tests passed (22.15s), 0 lint errors

## Key Files

### Modify

- `src/types.ts` — Add optional `inspectUrl` field to relevant interfaces
- `src/utils.ts` — Add `buildHtmlTableComment()`, update `buildCommentPrefix()`, update `buildCommentBody()`
- `src/github-comments.ts` — Update function signatures to accept and pass inspect URL
- `src/index.ts` — Wire inspect URL from `vercelInspect` through to comment functions
- `src/__tests__/utils.test.ts` — Tests for new HTML template builder and updated prefix logic
- `src/__tests__/github-comments.test.ts` — Tests for updated comment creation

### Reuse

- `src/vercel-api.ts` — `VercelApiClient.inspect()` already calls `/v13/deployments/{id}`, may need to extract inspector URL from response
- `src/config.ts` — No changes needed, `getGithubCommentInput()` already handles boolean vs string

## Verification

### Automated Tests

- [ ] `buildHtmlTableComment()` renders correct HTML table with all fields
- [ ] `buildHtmlTableComment()` omits alias row when no aliases configured
- [ ] `buildHtmlTableComment()` omits inspect row when no inspect URL available
- [ ] `buildCommentBody()` uses HTML table when `githubComment === true`
- [ ] `buildCommentBody()` still uses custom template when `githubComment` is a string
- [ ] Comment prefix detection finds both old and new format comments
- [ ] Integration test verifies HTML comment on PR

### Observable Outcomes

- After deploying with `github-comment: true`, the PR comment shows an HTML table with Project, Status, Preview URL, and Commit rows
- Running `pnpm test` shows all existing + new tests passing

### Acceptance Criteria Check

- [ ] AC-1: Default comment renders as HTML table with all required fields
- [ ] AC-2: Custom template still works with template variables
- [ ] AC-3: Previous comments are correctly detected and updated
- [ ] AC-4: Alias domains appear as additional rows when configured
- [ ] AC-5: Inspect URL row appears when available
- [ ] AC-6: Comment works on both PR and commit contexts

## Decision Log

- Decision: Use HTML table format over markdown table
Rationale: Better control over layout, no forced header row, key-value format suits single-project deployment info. Cloudflare Pages uses same approach.
Date/Author: 2026-03-29 / Claude

- Decision: Dual prefix detection for backward compatibility
Rationale: Existing PRs may have comments with old prefix format. Detecting both old and new ensures smooth transition without duplicate comments.
Date/Author: 2026-03-29 / Claude
51 changes: 51 additions & 0 deletions .please/docs/tracks/active/improve-github-comment-20260329/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Improve GitHub Deployment Comments

> Track: improve-github-comment-20260329

## Overview

Redesign the GitHub PR/commit deployment comment from plain text to a rich HTML table format, similar to Cloudflare Pages and Vercel's native deployment bots. The new format provides better readability with structured key-value rows for deployment status, preview URLs, commit info, and inspection links.

## Requirements

### Functional Requirements

- [ ] FR-1: Replace the default plain-text comment template with an HTML `<table>` layout showing deployment information in key-value rows
- [ ] FR-2: Include the following fields in the HTML table:
- **Project Name** — from `vercel-project-name` input or deployment name
- **Status** — deployment status with emoji indicator (✅ success / ❌ failed)
- **Preview URL** — clickable link to the deployment preview
- **Latest Commit** — short SHA in `<code>` format
- **Alias Domains** — show alias URLs when configured (conditional row)
- **Inspect URL** — link to Vercel deployment inspector (conditional row)
- [ ] FR-3: Add a subtle footer line: `Deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)`
- [ ] FR-4: Maintain backward compatibility — when `github-comment` input is a custom string (not `true`/`false`), use the custom template instead of the new HTML format
- [ ] FR-5: Update comment prefix matching logic to correctly find/update previous comments with the new format
- [ ] FR-6: Support both PR comments and commit comments with the same HTML table format

### Non-functional Requirements

- [ ] NFR-1: The HTML must render correctly in GitHub's comment markdown renderer
- [ ] NFR-2: Comment body should be under 65535 characters (GitHub API limit)

## Acceptance Criteria

- [ ] AC-1: Default comment (`github-comment: true`) renders as HTML table with all required fields
- [ ] AC-2: Custom template (`github-comment: '<custom string>'`) still works with template variables
- [ ] AC-3: Previous comments are correctly detected and updated (no duplicate comments)
- [ ] AC-4: Alias domains appear as additional rows when configured
- [ ] AC-5: Inspect URL row appears when deployment inspection data is available
- [ ] AC-6: Comment renders correctly on both PR and commit comment contexts

## Out of Scope

- Markdown table format (decided: HTML table)
- Configurable comment style toggle (single format for simplicity)
- Custom branding/logo images
- Dark mode specific styling

## Assumptions

- GitHub's markdown renderer supports `<table>`, `<tr>`, `<td>`, `<strong>`, `<code>`, `<a>` HTML tags in comments
- Vercel deployment inspector URL can be derived from the deployment URL or obtained from the Vercel API
- The `{{deploymentUrl}}`, `{{deploymentCommit}}`, `{{deploymentName}}` template variables remain available for custom templates
1 change: 1 addition & 0 deletions .please/docs/tracks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
|-------|---------|------|-------|---------|--------|
| [improve-test-coverage-20260326](active/improve-test-coverage-20260326/) | Improve Test Coverage | test | — | 2026-03-26 | draft |
| [emulate-integration-test-20260326](active/emulate-integration-test-20260326/) | Integration Tests with emulate.dev | feature | — | 2026-03-26 | in_progress |
| [improve-github-comment-20260329](active/improve-github-comment-20260329/) | Improve GitHub Deployment Comments | feature | #319 | 2026-03-29 | in_progress |

## Recently Completed

Expand Down
10 changes: 5 additions & 5 deletions src/__integration__/vercel-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@ describe('vercelApiClient (integration)', () => {
const created = await createRes.json()

const client = new VercelApiClient(createConfig(), process.env.EMULATE_VERCEL_URL)
const name = await client.inspect(created.id)
const result = await client.inspect(created.id)

expect(name).toBe(TEST_PROJECT)
expect(result.name).toBe(TEST_PROJECT)
})

it('should return null for non-existent deployment', async () => {
it('should return null name for non-existent deployment', async () => {
const client = new VercelApiClient(createConfig(), process.env.EMULATE_VERCEL_URL)
const name = await client.inspect('non-existent-id')
const result = await client.inspect('non-existent-id')

expect(name).toBeNull()
expect(result.name).toBeNull()
})
})

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/github-comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ describe('createCommentOnCommit', () => {
expect(mockUpdateCommitComment).not.toHaveBeenCalled()
const body = mockCreateCommitComment.mock.calls[0][0].body
expect(body).toContain('Deploy preview for _my-app_ ready!')
expect(body).toContain('<table>')
expect(body).toContain('https://deploy.vercel.app')
})

Expand Down
90 changes: 86 additions & 4 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
addVercelMetadata,
buildCommentBody,
buildCommentPrefix,
buildHtmlTableComment,
escapeHtml,
getGithubCommentInput,
isPullRequestType,
joinDeploymentUrls,
Expand Down Expand Up @@ -185,6 +187,68 @@ describe('buildCommentPrefix', () => {
})
})

describe('escapeHtml', () => {
it('escapes angle brackets', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;')
})

it('escapes quotes', () => {
expect(escapeHtml('\'"')).toBe('&#39;&quot;')
})

it('escapes ampersands', () => {
expect(escapeHtml('a&b')).toBe('a&amp;b')
})

it('leaves safe strings unchanged', () => {
expect(escapeHtml('https://example.vercel.app')).toBe('https://example.vercel.app')
})
})

describe('buildHtmlTableComment', () => {
it('renders HTML table with all fields', () => {
const result = buildHtmlTableComment('abc123def', 'https://example.vercel.app', 'my-app', [])
expect(result).toContain('<table>')
expect(result).toContain('</table>')
expect(result).toContain('<code>my-app</code>')
expect(result).toContain('Deploy successful!')
expect(result).toContain('href=\'https://example.vercel.app\'')
expect(result).toContain('<code>abc123d</code>')
})

it('omits alias rows when no aliases configured', () => {
const result = buildHtmlTableComment('abc123', 'https://example.vercel.app', 'my-app', [])
expect(result).not.toContain('Alias')
})

it('escapes HTML special characters in all fields', () => {
const result = buildHtmlTableComment('abc<123', 'https://example.com?x=\'><img>', 'app<xss>', [])
expect(result).not.toContain('<xss>')
expect(result).not.toContain('<img>')
expect(result).toContain('&lt;xss&gt;')
expect(result).toContain('&#39;&gt;&lt;img&gt;')
})

it('includes alias rows when configured', () => {
const result = buildHtmlTableComment('abc123', 'https://example.vercel.app', 'my-app', ['custom.com', 'alias.com'])
expect(result).toContain('https://custom.com')
expect(result).toContain('https://alias.com')
expect(result).toContain('Alias')
})

it('omits inspect row when no inspect URL', () => {
const result = buildHtmlTableComment('abc123', 'https://example.vercel.app', 'my-app', [])
expect(result).not.toContain('Inspect')
})

it('includes inspect row when URL provided', () => {
const result = buildHtmlTableComment('abc123', 'https://example.vercel.app', 'my-app', [], 'https://vercel.com/team/project/dpl_123')
expect(result).toContain('Inspect')
expect(result).toContain('href=\'https://vercel.com/team/project/dpl_123\'')
expect(result).toContain('View deployment')
})
})

describe('buildCommentBody', () => {
const defaultTemplate = `✅ Preview
{{deploymentUrl}}
Expand All @@ -202,20 +266,21 @@ Built with commit {{deploymentCommit}}.`
expect(result).toContain('Custom: https://example.com')
})

it('uses default template when githubComment is true', () => {
it('uses HTML table when githubComment is true', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'app', true, [], defaultTemplate)
expect(result).toContain('✅ Preview')
expect(result).toContain('<table>')
expect(result).toContain('https://example.com')
expect(result).toContain('Deploy successful!')
})

it('replaces all placeholders', () => {
it('includes all data in HTML table', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'my-app', true, [], defaultTemplate)
expect(result).toContain('abc123')
expect(result).toContain('https://example.com')
expect(result).toContain('my-app')
})

it('includes alias domains in output', () => {
it('includes alias domains in HTML table', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'app', true, ['custom.com'], defaultTemplate)
expect(result).toContain('https://custom.com')
})
Expand All @@ -224,6 +289,23 @@ Built with commit {{deploymentCommit}}.`
const result = buildCommentBody('abc123', 'https://example.com', 'my-app', true, [], defaultTemplate)
expect(result).toContain('Deploy preview for _my-app_ ready!')
})

it('includes footer branding link', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'my-app', true, [], defaultTemplate)
expect(result).toContain('Deployed with [vercel-action]')
expect(result).toContain('github.com/marketplace/actions/vercel-action')
})

it('passes inspect URL to HTML table', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'my-app', true, [], defaultTemplate, 'https://vercel.com/inspect')
expect(result).toContain('Inspect')
expect(result).toContain('https://vercel.com/inspect')
})

it('custom template still uses variable substitution', () => {
const result = buildCommentBody('abc123', 'https://example.com', 'my-app', '{{deploymentName}}: {{deploymentUrl}}', [], defaultTemplate)
expect(result).toContain('my-app: https://example.com')
})
})

describe('getGithubCommentInput', () => {
Expand Down
36 changes: 23 additions & 13 deletions src/__tests__/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,26 +258,26 @@ describe('vercelInspect', () => {
return 0
})

const name = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(name).toBe('my-project')
const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(result.name).toBe('my-project')
})

it('returns null when name not found in output', async () => {
it('returns null name when name not found in output', async () => {
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
options?.listeners?.stderr?.(Buffer.from('some other output\n'))
return 0
})

const name = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(name).toBeNull()
const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(result.name).toBeNull()
})

it('returns null and warns when exec fails', async () => {
it('returns null values and warns when exec fails', async () => {
vi.mocked(exec.exec).mockRejectedValue(new Error('command failed'))

const name = await vercelInspect(createClient(), 'https://deploy.vercel.app')
const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')

expect(name).toBeNull()
expect(result).toEqual({ name: null, inspectUrl: null })
expect(core.warning).toHaveBeenCalledWith(
'vercel inspect failed: command failed',
)
Expand All @@ -286,9 +286,19 @@ describe('vercelInspect', () => {
it('does not throw when exec fails', async () => {
vi.mocked(exec.exec).mockRejectedValue(new Error('network error'))

await expect(
vercelInspect(createClient(), 'https://deploy.vercel.app'),
).resolves.toBeNull()
const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(result).toEqual({ name: null, inspectUrl: null })
})

it('extracts inspectUrl from stderr when available', async () => {
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
options?.listeners?.stderr?.(Buffer.from(' name my-project\n inspectorUrl https://vercel.com/team/project/dpl_123\n'))
return 0
})

const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(result.name).toBe('my-project')
expect(result.inspectUrl).toBe('https://vercel.com/team/project/dpl_123')
})

it('passes correct args including token and inspect command', async () => {
Expand Down Expand Up @@ -328,8 +338,8 @@ describe('vercelInspect', () => {
return 0
})

const name = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(name).toBe('my-project-name')
const result = await vercelInspect(createClient(), 'https://deploy.vercel.app')
expect(result.name).toBe('my-project-name')
})
})

Expand Down
Loading
Loading