Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
8 changes: 5 additions & 3 deletions client/composables/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ export function useChangelog(_path?: MaybeRefOrGetter<string>): Ref<Changelog[]>
const strategy = gitLogOptions.value.contributor?.strategy

if (strategy === 'runtime') {
const contributors = computedAsync<Changelog[]>(
const changelog = computedAsync<Changelog[]>(
async () => {
// TODO: Complete the API-based method
// Runtime changelog is not yet supported via GitHub API
// (GitHub commits endpoint doesn't provide conventional-commit filtering)
// Fall back to empty β€” consumers should prefer 'prebuilt' or 'build-time'
return []
},
gitLog.value.changeLog,
{ lazy: true },
)

return contributors
return changelog
}

return computed(() => gitLog.value.changeLog)
Expand Down
90 changes: 86 additions & 4 deletions client/composables/gitlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ import { useFrontmatter } from 'valaxy'
import { computed, ref, toRef } from 'vue'
import { useAddonGitLogConfig } from '../options'

/**
* Module-level cache to avoid re-fetching git-log.json on every route navigation.
*/
let cachedData: GitLogFileEntry | null = null
let fetchPromise: Promise<GitLogFileEntry> | null = null

Comment thread
YunYouJun marked this conversation as resolved.
function getGitLogData(): Promise<GitLogFileEntry> {
if (cachedData)
return Promise.resolve(cachedData)
if (!fetchPromise) {
const base = import.meta.env.BASE_URL || '/'
const url = `${base}git-log.json`
fetchPromise = fetch(url)
.then((res) => {
if (!res.ok)
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`)
return res.json()
})
.then((data: GitLogFileEntry) => {
cachedData = data
return data
})
.catch((err) => {
// Allow retry on next call
fetchPromise = null
throw err
})
}
return fetchPromise
}

export function useGitLog(): Ref<GitLog> {
const initGitLog: GitLog = {
contributors: [],
Expand All @@ -24,10 +55,8 @@ export function useGitLog(): Ref<GitLog> {
const path = frontmatter.value.git_log?.path

if (isPrebuilt && path) {
const base = import.meta.env.BASE_URL || '/'
fetch(`${base}git-log.json`)
.then(res => res.json())
.then((data: GitLogFileEntry) => {
getGitLogData()
.then((data) => {
if (data[path])
gitLogData.value = data[path]
})
Expand All @@ -42,3 +71,56 @@ export function useGitLog(): Ref<GitLog> {

return gitLog
}

/**
* Extended version of useGitLog that also exposes loading and error state.
*/
export function useGitLogState() {
const initGitLog: GitLog = {
contributors: [],
changeLog: [],
path: '',
}

const isLoading = ref(false)
const error = ref<Error | null>(null)

if (!isClient) {
return {
data: toRef(initGitLog),
isLoading,
error,
}
Comment thread
YunYouJun marked this conversation as resolved.
}

const frontmatter = useFrontmatter<{ git_log: GitLog }>()
const gitLogOptions = useAddonGitLogConfig()
const gitLogData = ref<GitLog>(initGitLog)

const strategy = gitLogOptions.value.contributor?.strategy
const isPrebuilt = strategy === 'prebuilt'
const path = frontmatter.value.git_log?.path

if (isPrebuilt && path) {
isLoading.value = true
getGitLogData()
.then((data) => {
if (data[path])
gitLogData.value = data[path]
})
.catch((e) => {
error.value = e
})
.finally(() => {
isLoading.value = false
})
}

const data = computed<GitLog>(() => {
if (isPrebuilt)
return gitLogData.value
return frontmatter.value.git_log || initGitLog
})

return { data, isLoading, error }
}
40 changes: 28 additions & 12 deletions client/services/fetchContributors.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
import type { Contributor } from '../../types'
import { Octokit } from '@octokit/rest'
import gravatar from 'gravatar'
import md5 from 'md5'

const octokit = new Octokit()

/**
* Fetch contributors for a file path via the GitHub REST API.
* Uses native `fetch` instead of @octokit/rest to keep the client bundle small.
*
* Note: Unauthenticated requests are limited to 60/hour per IP.
*/
export async function fetchContributors(owner: string, repo: string, path: string): Promise<Contributor[]> {
let contributors: Contributor[] = []

try {
const { data } = await octokit.repos.listCommits({ owner, repo, path })
const contributorMap: { [key: string]: Contributor } = {}
const url = `https://api.github.com/repos/${owner}/${repo}/commits?path=${encodeURIComponent(path)}&per_page=100`
const res = await fetch(url, {
headers: { Accept: 'application/vnd.github.v3+json' },
})

if (!res.ok)
throw new Error(`GitHub API responded with ${res.status} ${res.statusText} (${url})`)

data.forEach(({ author, commit }) => {
const data: Array<{
author?: { login?: string, avatar_url?: string, name?: string, email?: string }
commit: { author?: { name?: string, email?: string } }
}> = await res.json()

const contributorMap: Record<string, Contributor> = {}

for (const { author, commit } of data) {
const name = author?.name || author?.login || commit.author?.name || 'Unknown Contributor'
const email = author?.email || commit.author?.email

if (!email)
return
continue

const github = author?.login ? `https://github.com/${author?.login}` : null
const github = author?.login ? `https://github.com/${author.login}` : null
const avatar = author?.avatar_url || `https:${gravatar.url(email, { d: 'wavatar' })}`
const hash = md5(email)

if (contributorMap[name])
contributorMap[name].count += 1
// Use email as key to avoid merging distinct contributors with the same name
if (contributorMap[email])
contributorMap[email].count += 1
else
contributorMap[name] = { count: 1, name, email, avatar, hash, github }
})
contributorMap[email] = { count: 1, name, email, avatar, hash, github }
}
Comment thread
YunYouJun marked this conversation as resolved.

contributors = Object.values(contributorMap)

Expand Down
26 changes: 9 additions & 17 deletions components/GitLogChangelog.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
<script setup lang="ts">
import { functions } from '@vueuse/metadata'
import changelog from 'virtual:git-log/changelog'
import type { Changelog } from '../types'
import { computed } from 'vue'
import { useAddonGitLogConfig } from '../client'
import { useAddonGitLogConfig, useChangelog } from '../client'
import { renderCommitMessage } from '../utils'

const props = defineProps<{ fn: string }>()
const props = defineProps<{
changelog?: Changelog[]
}>()

const gitLogOptions = useAddonGitLogConfig()
const repositoryUrl = computed(() => gitLogOptions.value.repositoryUrl || '')
const gitLogChangelog = useChangelog()

const info = computed(() => functions.find(i => i.name === props.fn))

const names = computed(() => [props.fn, ...info.value?.alias || []])
const commits = computed(() => {
const related = changelog
.filter(c => c.version || c.functions?.some(i => names.value.includes(i)))
return related.filter((i, idx) => {
if (i.version && (!related[idx + 1] || related[idx + 1]?.version))
return false
return true
})
})
const commits = computed(() => props.changelog || gitLogChangelog.value)
</script>

<template>
Expand Down Expand Up @@ -62,7 +54,7 @@ const commits = computed(() => {
</a>
<span text="sm">
-
<span v-html="renderCommitMessage(commit.message.replace(`(${fn})`, ''), repositoryUrl)" />
<span v-html="renderCommitMessage(commit.message, repositoryUrl)" />
</span>
</div>
</template>
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './client'
export * from './node'
export * from './types'
46 changes: 22 additions & 24 deletions node/changeLog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { Changelog } from '../types'
import { uniq } from '@vueuse/metadata'
import type { Changelog, GitLogOptions } from '../types'
import process from 'node:process'
import { git } from '.'
import { shouldIncludeCommit } from '../types'

/**
* ASCII Unit Separator β€” used as field delimiter in git pretty formats.
*/
const FIELD_SEP = '\x1F'

let cache: Changelog[] | undefined

Expand All @@ -12,18 +18,17 @@ const RE_PACKAGE_PATH = /^packages\/\w+\/(\w+)\/\w+\.ts$/
*/
const COMMIT_SEP = '---COMMIT_SEP---'

export async function getChangelog(maxCount = 200, path?: string) {
if (cache)
export async function getChangelog(maxCount = 200, path?: string, options?: GitLogOptions) {
// Only use cache when CI env var is set (CI builds call this multiple times for the virtual module)
if (cache && process.env.CI)
Comment thread
YunYouJun marked this conversation as resolved.
return cache

// Fetch commits together with their changed files in a single git call.
// This replaces the previous N+1 pattern (1 `git log` + N `git diff-tree`)
// that spawned ~100 sub-processes and took ~6 s.
const raw = await git.raw([
'log',
`--max-count=${maxCount}`,
'--name-only',
`--pretty=format:${COMMIT_SEP}%n%H|%an|%ae|%aI|%s|%b`,
`--pretty=format:${COMMIT_SEP}%n%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%s`,
Comment thread
YunYouJun marked this conversation as resolved.
...(path ? ['--', path] : []),
])

Expand All @@ -37,20 +42,12 @@ export async function getChangelog(maxCount = 200, path?: string) {
continue

const headerLine = lines[0]
const [hash, authorName, authorEmail, date, ...rest] = headerLine.split('|')
const messageParts = rest.join('|')
// The pretty format puts subject|body β€” split on first occurrence
const message = messageParts || ''
const [hash, authorName, authorEmail, date, ...rest] = headerLine.split(FIELD_SEP)
const message = rest.join(FIELD_SEP) || ''

// Apply the same filter as before
if (
!message.includes('chore: release')
&& !message.includes('!')
&& !message.startsWith('feat')
&& !message.startsWith('fix')
) {
// Apply configurable filter
if (!shouldIncludeCommit(message, options?.changelog))
continue
}

const log: Changelog = {
hash,
Expand All @@ -59,23 +56,24 @@ export async function getChangelog(maxCount = 200, path?: string) {
refs: '',
author_name: authorName,
author_email: authorEmail,
} as Changelog
}

if (message.includes('chore: release')) {
log.version = message.split(' ')[2]?.trim()
}
else {
// Changed files are on the remaining non-empty lines
const files = lines.slice(1).filter(Boolean).map(f => f.replace(RE_BACKSLASH, '/'))
log.functions = uniq(
log.functions = [...new Set(
files
.map(i => i.match(RE_PACKAGE_PATH)?.[1])
.filter(Boolean),
)
.filter((v): v is string => Boolean(v)),
)]
}

logs.push(log)
}

return cache = logs
cache = logs
return logs
}
Loading
Loading