2025-02-09 23:34:40 +01:00
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2025-08-02 23:13:50 +02:00
import 'dotenv/config' ;
2025-02-09 23:34:40 +01:00
type GithubAuthor = {
name : string ;
user : { login : string } | null ;
};
type GithubPullRequest = {
number : string ;
url : string ;
};
type GithubCommit = {
oid : string ;
url : string ;
message : string ;
authors : {
totalCount : number ;
nodes : GithubAuthor [];
};
associatedPullRequests : {
nodes : GithubPullRequest [];
};
};
type CommitQueryResponse = {
data : {
repository : {
ref : {
target : {
history : {
totalCount : number ;
pageInfo : {
hasNextPage : boolean ;
endCursor : string ;
};
nodes : GithubCommit [];
};
};
};
};
};
};
type Commit = {
repoUrl : string ;
revision : number ;
url : string ;
authors : GithubAuthor [];
title : string ;
pullRequest? : GithubPullRequest ;
};
const fetchCommits = async (
owner : string ,
repo : string ,
loadUntilSha : string ,
endCursor? : string ,
) : Promise < { totalRepoCommitCount : number ; commits : GithubCommit [] } > => {
const query = `query ($owner: String!, $name: String!, $afterSha: String) {
repository(owner: $owner, name: $name) {
ref(qualifiedName: "master") {
target {
... on Commit {
history(first: 100, after: $afterSha) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
oid
# use "message" instead of "messageHeadline" because GitHub truncates titles if they are too long
message
url
authors(first: 100) {
totalCount
nodes {
name
user {
login
}
}
}
associatedPullRequests(first: 1) {
nodes {
number
url
}
}
}
}
}
}
}
}
}` ;
const variables = {
owner ,
name : repo ,
afterSha : endCursor ,
};
const response = ( await (
await fetch ( 'https://api.github.com/graphql' , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
2025-08-02 23:13:50 +02:00
Authorization : `Bearer ${ process . env . GITHUB_TOKEN } ` ,
2025-02-09 23:34:40 +01:00
},
body : JSON.stringify ({ query , variables }),
})
). json ()) as CommitQueryResponse ;
const repoHistory = response . data . repository . ref . target . history ;
const { hasNextPage , endCursor : repoHistoryEndCursor } = repoHistory . pageInfo ;
const commits = repoHistory . nodes ;
const indexOfOldestCommitToLoad = commits . findIndex (( commit ) => commit . oid === loadUntilSha );
const loadedAllCommits = indexOfOldestCommitToLoad !== - 1 ;
if ( loadedAllCommits ) {
return { totalRepoCommitCount : repoHistory.totalCount , commits : commits.slice ( 0 , indexOfOldestCommitToLoad ) };
}
if ( ! hasNextPage ) {
throw new Error ( `No commit with sha " ${ loadUntilSha } " found!` );
}
const loadedCommits = [
... commits ,
...( await fetchCommits ( owner , repo , loadUntilSha , repoHistoryEndCursor )). commits ,
];
return { totalRepoCommitCount : repoHistory.totalCount , commits : loadedCommits };
};
const createCommitAuthorCredit = ( authors : GithubAuthor []) : string => {
const authorsWithGithubAccount = authors . filter (( author ) => !! author . user ? . login );
if ( ! authorsWithGithubAccount . length ) {
return `by @ ${ authors [ 0 ]. name } ` ;
}
const commitAuthorsString = authorsWithGithubAccount
. map (( author ) => author . user ! . login )
. reduce (( authorCredit , author ) => ` ${ authorCredit } , @ ${ author } ` );
return `by @ ${ commitAuthorsString } ` ;
};
const createChangelogCommitLine = ( commit : Commit ) : string => {
try {
const authorCredit = createCommitAuthorCredit ( commit . authors );
const revision = `([r ${ commit . revision } ]( ${ commit . url } ))` ;
if ( ! commit . pullRequest ) {
return ` ${ revision } ${ commit . title } ( ${ authorCredit } )` ;
}
2026-03-11 00:11:29 +01:00
const title = commit . title . replaceAll ( /(.*) \(#[0-9]+\)$/g , '$1' ); // remove the possible pr number from the title (e.g. "my commit title (#420)" => "my commit title")
2025-02-09 23:34:40 +01:00
const prId = commit . pullRequest . number ;
const prUrl = commit . pullRequest . url ;
const prLink = `[# ${ prId } ]( ${ prUrl } )` ;
return ` ${ revision } ${ title } ( ${ prLink } ${ authorCredit } )` ;
} catch ( e ) {
console . log ( 'Unexpected commit format' , commit );
throw e ;
}
};
const getContributors = ( commits : Commit []) : string [] => [
... new Set (
2026-03-11 00:11:29 +01:00
commits . flatMap (( commit ) =>
commit . authors . map (( author ) => author . user ? . login ). filter (( author ) : author is string => !! author ),
),
2025-02-09 23:34:40 +01:00
),
];
export const createCommitChangelog = async ( prevReleaseLastCommitSha : string ) : Promise < string > => {
const owner = 'Suwayomi' ;
const repo = 'Suwayomi-WebUI' ;
const { totalRepoCommitCount : numberOfCommits , commits : githubCommits } = await fetchCommits (
owner ,
repo ,
prevReleaseLastCommitSha ,
);
const commits : Commit [] = githubCommits . map (( githubCommit , index ) => ({
repoUrl : `https://github.com/ ${ owner } / ${ repo } ` ,
revision : numberOfCommits - index ,
url : githubCommit.url ,
authors : githubCommit.authors.nodes ,
title : githubCommit.message.split ( '\n' )[ 0 ],
pullRequest : githubCommit.associatedPullRequests.nodes [ 0 ],
}));
let changelog = '' ;
const commitChangelogLines = commits . map ( createChangelogCommitLine );
commitChangelogLines . forEach (( commit ) => {
changelog += `- ${ commit } \ n` ;
});
const contributors = getContributors ( commits );
const contributorString = contributors . reduce (( authorCredit , author ) => ` ${ authorCredit } , @ ${ author } ` );
changelog += ` \ nContributors: \ n@ ${ contributorString } \ n` ;
return changelog ;
};