Skip to content
Open
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
50 changes: 50 additions & 0 deletions .github/workflows/build-action-code.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Build Action Code

on:
push:
paths:
- 'index.ts'
- 'scripts/**'
- 'api/**'
- 'action.yml'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/build-action-code.yml'
pull_request:
paths:
- 'index.ts'
- 'scripts/**'
- 'api/**'
- 'action.yml'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/build-action-code.yml'
workflow_dispatch:

jobs:
build-action:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Build action bundle
run: npm run build:action

- name: Ensure scripts bundle is up to date
run: |
if ! git diff --quiet -- scripts/update-markdown-pr-stats.cjs; then
echo 'scripts/update-markdown-pr-stats.cjs is out of date. Run npm run build:action and commit the result.'
git --no-pager diff -- scripts/update-markdown-pr-stats.cjs
exit 1
fi
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,57 @@ Visit your deployed instance to use our visual parameter debugger:
- **[🚀 Deployment Guide](docs/deployment.md)** - Setup instructions and configuration
- **[👥 User Guide](docs/user-guide.md)** - Tips, use cases, and best practices

## 🔁 GitHub Action (Markdown Updater)

Use this action from any repository to auto-update a markdown region.

```yaml
name: Update My PR Stats

on:
schedule:
- cron: '15 2 * * *'
workflow_dispatch:

permissions:
contents: write

jobs:
update-pr-stats:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Update markdown region
id: update
uses: f14XuanLv/github-pr-stats@main
with:
username: ${{ github.repository_owner }}
file: README.md
commit_changes: true
commit_message: 'docs: refresh PR stats'
github_token: ${{ secrets.GITHUB_TOKEN }}
mode: pr-list
theme: dark
status: all
min_stars: 0
limit: 10
sort: status,stars_desc
stats: total_pr,merged_pr,display_pr
fields: repo,stars,pr_title,pr_number,status,created_date,merged_date

- name: Show update result
run: echo "changed=${{ steps.update.outputs.changed }}"
```

Add this region to your markdown file before running the workflow:

```markdown
<!-- region github-pr-stats -->
<!-- endregion -->
```

## 🎨 Themes & Customization

Choose from multiple themes and customize every aspect:
Expand Down
53 changes: 53 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: GitHub PR Stats Markdown Updater
description: Update a markdown region with a table generated from GitHub PR data.

inputs:
username:
description: GitHub username to analyze.
required: false
default: ${{ github.repository_owner }}
file:
description: Markdown file path to update.
required: false
default: README.md
mode:
description: Output mode (pr-list or repo-aggregate).
required: false
theme:
description: Theme (dark or light).
required: false
status:
description: PR status filter (used in pr-list mode).
required: false
min_stars:
description: Minimum repository stars.
required: false
limit:
description: Maximum rows in table.
required: false
sort:
description: Sort expression.
required: false
stats:
description: Stats fields list.
required: false
fields:
description: Display fields list.
required: false
commit_changes:
description: Whether to commit and push markdown updates.
required: false
commit_message:
description: Commit message used when markdown changes are detected.
required: false
github_token:
description: GitHub token for API access and optional push operations.
required: false

outputs:
changed:
description: Whether the action changed the target markdown file.

runs:
using: node24
main: scripts/update-markdown-pr-stats.cjs
2 changes: 1 addition & 1 deletion api/github-pr-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}
}

function parseQueryParams(query: VercelRequest['query']): APIParams {
export function parseQueryParams(query: VercelRequest['query']): APIParams {
const getString = (key: string): string | undefined => {
const value = query[key]
return Array.isArray(value) ? value[0] : value
Expand Down
154 changes: 154 additions & 0 deletions api/utils/markdown_generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { ProcessedPR, RepoAggregate, PRStats, APIParams } from '../types.js'

type PRFieldKey = 'repo' | 'stars' | 'pr_title' | 'pr_number' | 'status' | 'created_date' | 'merged_date'
type RepoFieldKey = 'repo' | 'stars' | 'pr_numbers' | 'total' | 'merged' | 'open' | 'draft' | 'closed' | 'merged_rate'
type StatsFieldKey = 'total_pr' | 'merged_pr' | 'display_pr' | 'repos_with_pr' | 'repos_with_merged_pr' | 'showing_repos'

type FieldsConfig<T extends string | number | symbol, D> = {
[K in T]: {
label: string
align: 'left' | 'right',
format: (data: D) => string
}
}

export class MarkdownGenerator {
private static readonly FIELD_CONFIGS: FieldsConfig<PRFieldKey&RepoFieldKey, any> = {
repo: { label: 'Repository', align: 'left', format: pr => `[${pr.repo}](https://github.com/${pr.repo})` },
stars: { label: 'Stars', align: 'right', format: pr => `${pr.stars.toString()} ⭐` },
}
private static readonly PRFIELD_CONFIGS: FieldsConfig<PRFieldKey, ProcessedPR> = {
...MarkdownGenerator.FIELD_CONFIGS,
pr_title: { label: 'PR Title', align: 'left', format: pr => `[${MarkdownGenerator.escapeMarkdownCell(pr.pr_title)}](${pr.url})` },
pr_number: { label: 'PR #', align: 'right', format: pr => `[#${pr.pr_number}](${pr.url})` },
status: { label: 'Status', align: 'left', format: pr => pr.status },
created_date: { label: 'Created', align: 'right', format: pr => pr.created_date },
merged_date: { label: 'Merged', align: 'right', format: pr => pr.merged_date ?? '-' },

}
private static readonly REPOFIELD_CONFIGS: FieldsConfig<RepoFieldKey, RepoAggregate> = {
...MarkdownGenerator.FIELD_CONFIGS,
pr_numbers: { label: 'PR Numbers', align: 'left', format: repo => MarkdownGenerator.escapeMarkdownCell(repo.pr_numbers.map(
num => `[#${num}](https://github.com/${repo.repo}/pull/${num})`
).join(', ')) },
total: { label: 'Total', align: 'right', format: repo => repo.total.toString() },
merged: { label: 'Merged', align: 'right', format: repo => repo.merged.toString() },
open: { label: 'Open', align: 'right', format: repo => repo.open.toString() },
draft: { label: 'Draft', align: 'right', format: repo => repo.draft.toString() },
closed: { label: 'Closed', align: 'right', format: repo => repo.closed.toString() },
merged_rate: { label: 'Merged Rate', align: 'right', format: repo => `${repo.merged_rate}%` }
}

private static readonly STATS_FIELD_CONFIGS: FieldsConfig<StatsFieldKey, PRStats> = {
total_pr: { label: 'Total PRs', align: 'right', format: stats => stats.total_pr.toString() },
merged_pr: { label: 'Merged PRs', align: 'right', format: stats => stats.merged_pr.toString() },
display_pr: { label: 'Display PRs', align: 'right', format: stats => stats.display_pr.toString() },
repos_with_pr: { label: 'Repos(≥1 PR)', align: 'right', format: stats => stats.repos_with_pr.toString() },
repos_with_merged_pr: { label: 'Repos(≥1 Merged PR)', align: 'right', format: stats => stats.repos_with_merged_pr.toString() },
showing_repos: { label: 'Showing Repos', align: 'right', format: stats => stats.showing_repos.toString() }
}

static generate(
_username: string,
prs: ProcessedPR[],
_stats: PRStats,
params: APIParams,
repos?: RepoAggregate[]
): string {
let summaryFields: Partial<FieldsConfig<StatsFieldKey, PRStats>>;
if (params.stats == 'all' || !params.stats) {
summaryFields = this.STATS_FIELD_CONFIGS
} else {
const selectedKeys = params.stats
.split(',')
.map(s => s.trim())
.filter((s): s is StatsFieldKey => s in this.STATS_FIELD_CONFIGS)
summaryFields = selectedKeys.reduce((acc, key) => {
acc[key] = this.STATS_FIELD_CONFIGS[key]
return acc
}, {} as Partial<FieldsConfig<StatsFieldKey, PRStats>>)
}
const summary = Object.entries(summaryFields)
.map(([, config]) => {
if (!config) return null
return `**${config.label}:** ${config.format(_stats as PRStats)}`
})
.filter((segment): segment is string => segment !== null)
.join(' | ')

let markdownTable = ''
if (params.mode === 'repo-aggregate') {
const repoRows = repos || []
const fields = params.fields || 'repo,stars,pr_numbers,total,merged,open,draft,closed,merged_rate'
markdownTable = this.generateRepoTable(repoRows, fields)
} else {
const fields = params.fields || 'repo,stars,pr_title,pr_number,status,created_date,merged_date'
markdownTable = this.generatePRTable(prs, fields)
}
return [
'\n',
summary,
'\n',
markdownTable,
'\n'
].join('')
}

private static generatePRTable(prs: ProcessedPR[], fieldsParam: string): string {
const fields = this.parseFields(fieldsParam, this.PRFIELD_CONFIGS)

const header = [
`| ${fields.map((field) => this.PRFIELD_CONFIGS[field].label).join(' | ')} |`,
`| ${fields.map((field) => this.getAlignmentMarker(this.PRFIELD_CONFIGS[field].align)).join(' | ')} |`
]

const rows = prs.map((pr) => `| ${fields.map((field) => this.PRFIELD_CONFIGS[field].format(pr)).join(' | ')} |`)

return [...header, ...rows].join('\n')
}

private static generateRepoTable(repos: RepoAggregate[], fieldsParam: string): string {
const fields = this.parseFields(fieldsParam, this.REPOFIELD_CONFIGS)

const header = [
`| ${fields.map((field) => this.REPOFIELD_CONFIGS[field].label).join(' | ')} |`,
`| ${fields.map((field) => this.getAlignmentMarker(this.REPOFIELD_CONFIGS[field].align)).join(' | ')} |`
]

const rows = repos.map((repo) => `| ${fields.map((field) => this.REPOFIELD_CONFIGS[field].format(repo)).join(' | ')} |`)

return [...header, ...rows].join('\n')
}

private static parseFields<T extends PRFieldKey | RepoFieldKey>(
fieldsParam: string,
availableFields: Readonly<Record<T, any>>
): T[] {
const fieldKeys = fieldsParam.split(',').map((field) => field.trim())
const fields: T[] = []

if (!fieldKeys.includes('repo')) {
fields.push('repo' as T)
}

for (const key of fieldKeys) {
if (availableFields.hasOwnProperty(key as T)) {
fields.push(key as T)
}
}

if (fields.length === 0) {
fields.push('repo' as T)
}

return fields
}

private static getAlignmentMarker(align: 'left' | 'right'): string {
return align === 'right' ? '---:' : '---'
}

private static escapeMarkdownCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
}
}
Loading