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
15 changes: 15 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ export function githubNewReleaseURL(
}&title=v${release.version}&body=${encodeURIComponent(release.body)}`;
}

export async function getPullRequestAuthorLogin(
config: ResolvedChangelogConfig,
prNumber: number
): Promise<string | undefined> {
try {
const pr = await githubFetch(
config,
`/repos/${config.repo.repo}/pulls/${prNumber}`
);
return pr?.user?.login;
} catch {
return undefined;
}
}

export async function resolveGithubToken(config: ResolvedChangelogConfig) {
const env =
process.env.CHANGELOGEN_TOKENS_GITHUB ||
Expand Down
41 changes: 39 additions & 2 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { upperFirst } from "scule";
import { convert } from "convert-gitmoji";
import { fetch } from "node-fetch-native";
import { upperFirst } from "scule";
import type { ResolvedChangelogConfig } from "./config";
import type { GitCommit, Reference } from "./git";
import { formatReference, formatCompareChanges } from "./repo";
import { getPullRequestAuthorLogin } from "./github";
import { formatCompareChanges, formatReference } from "./repo";

export async function generateMarkDown(
commits: GitCommit[],
Expand Down Expand Up @@ -82,6 +83,19 @@ export async function generateMarkDown(
break;
}
}

// Fallback: Use PR author login for any PRs referenced by this author's commits
if (!meta.github && config.repo?.provider === "github") {
const prNumber = getAuthorPRNumber(commits, authorName);
if (prNumber) {
const login = await getPullRequestAuthorLogin(config, prNumber).catch(
() => undefined
);
if (login) {
meta.github = login;
}
}
}
})
);

Expand Down Expand Up @@ -188,6 +202,29 @@ function groupBy(items: any[], key: string) {
return groups;
}

function getAuthorPRNumber(
commits: GitCommit[],
authorName: string
): number | undefined {
for (const commit of commits) {
if (!commit.author) continue;
const name = formatName(commit.author.name);
if (name !== authorName) continue;

if (commit.references && Array.isArray(commit.references)) {
for (const ref of commit.references) {
if (ref?.type === "pull-request" && typeof ref.value === "string") {
const num = Number.parseInt(ref.value.replace("#", ""), 10);
if (Number.isFinite(num)) {
return num;
}
}
}
}
}
return undefined;
}

const CHANGELOG_RELEASE_HEAD_RE =
/^#{2,}\s+.*(v?(\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)).*$/gm;

Expand Down
52 changes: 51 additions & 1 deletion test/contributors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { loadChangelogConfig, generateMarkDown } from "../src";
import { testCommits } from "./fixtures/commits";

// Mock fetch to prevent network calls during tests
vi.mock("node-fetch-native", () => ({
fetch: vi.fn((url: string) => {
if (url.includes("[email protected]")) {
return Promise.resolve({
json: () => Promise.resolve({ user: { username: "brainsucker" } }),
});
}
return Promise.resolve({
json: () => Promise.resolve({ user: null }),
});
}),
}));

describe("contributors", () => {
test("should include authors", async () => {
const config = await loadChangelogConfig(process.cwd(), {
Expand Down Expand Up @@ -108,4 +122,40 @@ describe("contributors", () => {
- Bob Williams"
`);
});

test("should handle PR author fallback gracefully", async () => {
const config = await loadChangelogConfig(process.cwd(), {
from: "1.0.0",
newVersion: "2.0.0",
repo: "unjs/changelogen",
});

const testCommits = [
{
author: {
name: "PR Author",
email: "[email protected]",
},
message: "feat: add feature (#123)",
shortHash: "abc123",
body: "body",
type: "feat",
description: "add feature (#123)",
scope: "",
references: [
{
type: "pull-request" as const,
value: "#123",
},
],
authors: [],
isBreaking: false,
},
];

const contents = await generateMarkDown(testCommits, config);

// Should include author name (PR fallback may or may not work depending on network/config)
expect(contents).toContain("PR Author");
});
});
243 changes: 243 additions & 0 deletions test/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { loadChangelogConfig } from "../src";
import {
getGithubChangelog,
getGithubReleaseByTag,
getPullRequestAuthorLogin,
githubNewReleaseURL,
listGithubReleases,
resolveGithubToken,
syncGithubRelease,
} from "../src/github";

vi.mock("ofetch", () => ({
$fetch: vi.fn(),
}));

describe("github", () => {
beforeEach(() => {
vi.resetAllMocks();
});

test("getPullRequestAuthorLogin should return undefined when API call fails", async () => {
const { $fetch } = await import("ofetch");
vi.mocked($fetch).mockRejectedValueOnce(new Error("API Error"));

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const result = await getPullRequestAuthorLogin(config, 123);
expect(result).toBeUndefined();
});

test("listGithubReleases should fetch releases with pagination", async () => {
const { $fetch } = await import("ofetch");
const mockReleases = [{ tag_name: "v1.0.0" }, { tag_name: "v0.9.0" }];
vi.mocked($fetch).mockResolvedValueOnce(mockReleases);

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const releases = await listGithubReleases(config);
expect(releases).toEqual(mockReleases);
expect($fetch).toHaveBeenCalledWith("/repos/test/repo/releases", {
baseURL: "https://api.github.com",
headers: {
"x-github-api-version": "2022-11-28",
authorization: undefined,
},
query: { per_page: 100 },
});
});

test("getGithubReleaseByTag should fetch specific release", async () => {
const { $fetch } = await import("ofetch");
const mockRelease = { tag_name: "v1.0.0", body: "Release notes" };
vi.mocked($fetch).mockResolvedValueOnce(mockRelease);

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const release = await getGithubReleaseByTag(config, "v1.0.0");
expect(release).toEqual(mockRelease);
expect($fetch).toHaveBeenCalledWith(
"/repos/test/repo/releases/tags/v1.0.0",
{
baseURL: "https://api.github.com",
headers: {
"x-github-api-version": "2022-11-28",
authorization: undefined,
},
}
);
});

test("syncGithubRelease should create new release when none exists", async () => {
const { $fetch } = await import("ofetch");
vi.mocked($fetch).mockRejectedValueOnce(new Error("Not found"));
vi.mocked($fetch).mockResolvedValueOnce({ id: "new-release-id" });

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
tokens: { github: "test-token" },
});

const result = await syncGithubRelease(config, {
version: "1.0.0",
body: "Release notes",
});

expect(result).toEqual({
status: "created",
id: "new-release-id",
});
});

test("syncGithubRelease should return manual URL when no token", async () => {
const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const result = await syncGithubRelease(config, {
version: "1.0.0",
body: "Release notes",
});

expect(result).toEqual({
status: "manual",
url: expect.stringContaining("/releases/new?tag=v1.0.0"),
});
});

test("githubNewReleaseURL should generate correct URL with encoded body", async () => {
const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const url = githubNewReleaseURL(config, {
version: "1.0.0",
body: "Release notes with spaces & special chars",
});

expect(url).toBe(
"https://github.com/test/repo/releases/new?tag=v1.0.0&title=v1.0.0&body=Release%20notes%20with%20spaces%20%26%20special%20chars"
);
});

test("GitHub Enterprise API URLs should be handled correctly", async () => {
const { $fetch } = await import("ofetch");
vi.mocked($fetch).mockResolvedValueOnce([]);

const config = await loadChangelogConfig(process.cwd(), {
tokens: { github: "test-token" },
// Override the domain to simulate enterprise GitHub
repo: {
domain: "github.enterprise.com",
repo: "test/repo",
},
});

await listGithubReleases(config);

expect($fetch).toHaveBeenCalledWith("/repos/test/repo/releases", {
baseURL: "https://github.enterprise.com/api/v3",
headers: {
"x-github-api-version": "2022-11-28",
authorization: "Bearer test-token",
},
query: { per_page: 100 },
});
});

test("getGithubChangelog should fetch changelog from main branch", async () => {
const { $fetch } = await import("ofetch");
const mockChangelog = "# Changelog";
vi.mocked($fetch).mockResolvedValueOnce(mockChangelog);

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const changelog = await getGithubChangelog(config);
expect(changelog).toBe(mockChangelog);
expect($fetch).toHaveBeenCalledWith(
"https://raw.githubusercontent.com/test/repo/main/CHANGELOG.md",
expect.any(Object)
);
});

test("syncGithubRelease should handle update errors", async () => {
const { $fetch } = await import("ofetch");
vi.mocked($fetch).mockResolvedValueOnce({ id: "existing-id" });
vi.mocked($fetch).mockRejectedValueOnce(new Error("Update failed"));

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
tokens: { github: "test-token" },
});

const result = await syncGithubRelease(config, {
version: "1.0.0",
body: "Release notes",
});

expect(result).toEqual({
status: "manual",
error: expect.any(Error),
url: expect.stringContaining("/releases/new?tag=v1.0.0"),
});
});

test("resolveGithubToken should handle environment variables", async () => {
const originalEnv = process.env;
process.env = { ...originalEnv, GITHUB_TOKEN: "test-token" };

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const token = await resolveGithubToken(config);
expect(token).toBe("test-token");

process.env = originalEnv;
});

test("resolveGithubToken should try multiple environment variables", async () => {
const originalEnv = process.env;
process.env = {
...originalEnv,
CHANGELOGEN_TOKENS_GITHUB: "changelogen-token",
GITHUB_TOKEN: "github-token",
GH_TOKEN: "gh-token",
};

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const token = await resolveGithubToken(config);
expect(token).toBe("changelogen-token"); // Should use first available token

process.env = originalEnv;
});

test("resolveGithubToken should return undefined when no token sources available", async () => {
const originalEnv = process.env;
process.env = { ...originalEnv }; // Clear token env vars
delete process.env.CHANGELOGEN_TOKENS_GITHUB;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;

const config = await loadChangelogConfig(process.cwd(), {
repo: "test/repo",
});

const token = await resolveGithubToken(config);
expect(token).toBeUndefined();

process.env = originalEnv;
});
});
Loading