feat: add organization allowlist and denylist filters, enhance changelog loading, and improve fetch functions

- Introduced orgAllowlist and orgDenylist properties to filter organizations during fetching.
- Enhanced loadChangelogFromRepo to check multiple potential changelog file names.
- Updated fetchTags to return a map of tag names associated with their SHAs.
- Improved pagination logic in fetchAllOrganizations and fetchRepositoriesForOrg to handle larger datasets.
- Added retry logic in fetchFunction to handle rate limiting and server errors more gracefully.
- Modified ITag interface to include an optional name property for better tag handling.
This commit is contained in:
2025-09-12 22:06:02 +00:00
parent d0a00aedea
commit 98f5c466a6
4 changed files with 2951 additions and 3079 deletions

View File

@@ -16,17 +16,17 @@
"buildDocs": "(tsdoc)" "buildDocs": "(tsdoc)"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.3.2", "@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.2.5", "@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.2.46", "@git.zone/tsrun": "^1.2.46",
"@git.zone/tstest": "^1.0.96", "@git.zone/tstest": "^2.3.8",
"@push.rocks/tapbundle": "^5.6.3", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.15.2" "@types/node": "^22.15.2"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.2.2", "@push.rocks/lik": "^6.2.2",
"@push.rocks/qenv": "^6.1.0", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartnpm": "^2.0.4", "@push.rocks/smartnpm": "^2.0.6",
"@push.rocks/smarttime": "^4.1.1", "@push.rocks/smarttime": "^4.1.1",
"@push.rocks/smartxml": "^1.1.1" "@push.rocks/smartxml": "^1.1.1"
}, },

5808
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ export class CodeFeed {
private baseUrl: string; private baseUrl: string;
private token?: string; private token?: string;
private lastRunTimestamp: string; private lastRunTimestamp: string;
private pageLimit = 50;
// Raw changelog content for the current repository // Raw changelog content for the current repository
private changelogContent: string = ''; private changelogContent: string = '';
// npm registry helper for published-on-npm checks // npm registry helper for published-on-npm checks
@@ -16,6 +17,9 @@ export class CodeFeed {
private enableNpmCheck: boolean = true; private enableNpmCheck: boolean = true;
// return only tagged commits (false by default) // return only tagged commits (false by default)
private enableTaggedOnly: boolean = false; private enableTaggedOnly: boolean = false;
// allow/deny filters
private orgAllowlist?: string[];
private orgDenylist?: string[];
constructor( constructor(
baseUrl: string, baseUrl: string,
@@ -26,6 +30,8 @@ export class CodeFeed {
cacheWindowMs?: number; cacheWindowMs?: number;
enableNpmCheck?: boolean; enableNpmCheck?: boolean;
taggedOnly?: boolean; taggedOnly?: boolean;
orgAllowlist?: string[];
orgDenylist?: string[];
} }
) { ) {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
@@ -37,6 +43,8 @@ export class CodeFeed {
this.cacheWindowMs = options?.cacheWindowMs; this.cacheWindowMs = options?.cacheWindowMs;
this.enableNpmCheck = options?.enableNpmCheck ?? true; this.enableNpmCheck = options?.enableNpmCheck ?? true;
this.enableTaggedOnly = options?.taggedOnly ?? false; this.enableTaggedOnly = options?.taggedOnly ?? false;
this.orgAllowlist = options?.orgAllowlist;
this.orgDenylist = options?.orgDenylist;
this.cache = []; this.cache = [];
// npm registry instance for version lookups // npm registry instance for version lookups
this.npmRegistry = new plugins.smartnpm.NpmRegistry(); this.npmRegistry = new plugins.smartnpm.NpmRegistry();
@@ -61,7 +69,14 @@ export class CodeFeed {
} }
// 1) get all organizations // 1) get all organizations
const orgs = await this.fetchAllOrganizations(); let orgs = await this.fetchAllOrganizations();
// apply allow/deny filters
if (this.orgAllowlist && this.orgAllowlist.length > 0) {
orgs = orgs.filter((o) => this.orgAllowlist!.includes(o));
}
if (this.orgDenylist && this.orgDenylist.length > 0) {
orgs = orgs.filter((o) => !this.orgDenylist!.includes(o));
}
// 2) fetch repos per org in parallel // 2) fetch repos per org in parallel
const repoLists = await Promise.all( const repoLists = await Promise.all(
@@ -122,11 +137,15 @@ export class CodeFeed {
await this.loadChangelogFromRepo(owner, name); await this.loadChangelogFromRepo(owner, name);
// fetch tags for this repo // fetch tags for this repo
let taggedShas: Set<string>; let taggedShas: Set<string>;
let tagNameBySha: Map<string, string>;
try { try {
taggedShas = await this.fetchTags(owner, name); const tagInfo = await this.fetchTags(owner, name);
taggedShas = tagInfo.shas;
tagNameBySha = tagInfo.map;
} catch (e: any) { } catch (e: any) {
console.error(`Failed to fetch tags for ${owner}/${name}:`, e.message); console.error(`Failed to fetch tags for ${owner}/${name}:`, e.message);
taggedShas = new Set<string>(); taggedShas = new Set<string>();
tagNameBySha = new Map<string, string>();
} }
// fetch npm package info only if any new commits correspond to a tag // fetch npm package info only if any new commits correspond to a tag
const hasTaggedCommit = commits.some((c) => taggedShas.has(c.sha)); const hasTaggedCommit = commits.some((c) => taggedShas.has(c.sha));
@@ -141,14 +160,23 @@ export class CodeFeed {
} }
// build commit entries // build commit entries
for (const c of commits) { for (const c of commits) {
const versionCandidate = c.commit.message.replace(/\n/g, '').trim();
const isTagged = taggedShas.has(c.sha); const isTagged = taggedShas.has(c.sha);
const publishedOnNpm = isTagged && pkgInfo // derive version from tag name if present (strip leading 'v')
? pkgInfo.allVersions.some((v) => v.version === versionCandidate) let versionFromTag: string | undefined;
if (isTagged) {
const tagName = tagNameBySha.get(c.sha);
if (tagName) {
versionFromTag = tagName.startsWith('v') ? tagName.substring(1) : tagName;
}
}
const publishedOnNpm = isTagged && pkgInfo && versionFromTag
? pkgInfo.allVersions.some((v) => v.version === versionFromTag)
: false; : false;
let changelogEntry: string | undefined; let changelogEntry: string | undefined;
if (this.changelogContent) { if (this.changelogContent) {
changelogEntry = this.getChangelogForVersion(versionCandidate); if (versionFromTag) {
changelogEntry = this.getChangelogForVersion(versionFromTag);
}
} }
newResults.push({ newResults.push({
baseUrl: this.baseUrl, baseUrl: this.baseUrl,
@@ -187,40 +215,49 @@ export class CodeFeed {
return this.cache; return this.cache;
} }
// no caching: apply tagged-only filter if requested // no caching: apply tagged-only filter if requested
// sort and dedupe
const seen = new Set<string>();
const unique = newResults.filter((c) => {
if (seen.has(c.hash)) return false;
seen.add(c.hash);
return true;
});
unique.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
if (this.enableTaggedOnly) { if (this.enableTaggedOnly) {
return newResults.filter((c) => c.tagged === true); return unique.filter((c) => c.tagged === true);
} }
return newResults; return unique;
} }
/** /**
* Load the changelog directly from the Gitea repository. * Load the changelog directly from the Gitea repository.
*/ */
private async loadChangelogFromRepo(owner: string, repo: string): Promise<void> { private async loadChangelogFromRepo(owner: string, repo: string): Promise<void> {
const url = `/api/v1/repos/${owner}/${repo}/contents/changelog.md`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (this.token) { if (this.token) headers['Authorization'] = `token ${this.token}`;
headers['Authorization'] = `token ${this.token}`; const candidates = [
'CHANGELOG.md',
'changelog.md',
'Changelog.md',
'docs/CHANGELOG.md',
];
for (const path of candidates) {
const url = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
const response = await this.fetchFunction(url, { headers });
if (!response.ok) {
continue;
}
try {
const data = await response.json();
if (data && data.content) {
this.changelogContent = Buffer.from(data.content, 'base64').toString('utf8');
return;
}
} catch {
// continue trying others
}
} }
this.changelogContent = '';
const response = await this.fetchFunction(url, { headers });
if (!response.ok) {
console.error(
`Could not fetch CHANGELOG.md from ${owner}/${repo}: ${response.status} ${response.statusText}`
);
this.changelogContent = '';
return;
}
const data = await response.json();
if (!data.content) {
console.warn(`No content field found in response for ${owner}/${repo}/changelog.md`);
this.changelogContent = '';
return;
}
// decode base64 content
this.changelogContent = Buffer.from(data.content, 'base64').toString('utf8');
} }
/** /**
@@ -257,49 +294,68 @@ export class CodeFeed {
/** /**
* Fetch all tags for a given repo and return the set of tagged commit SHAs * Fetch all tags for a given repo and return the set of tagged commit SHAs
*/ */
private async fetchTags(owner: string, repo: string): Promise<Set<string>> { private async fetchTags(owner: string, repo: string): Promise<{ shas: Set<string>; map: Map<string, string> }> {
const taggedShas = new Set<string>(); const taggedShas = new Set<string>();
const tagNameBySha = new Map<string, string>();
let page = 1; let page = 1;
while (true) { while (true) {
const url = `/api/v1/repos/${owner}/${repo}/tags?limit=50&page=${page}`; const url = `/api/v1/repos/${owner}/${repo}/tags?limit=${this.pageLimit}&page=${page}`;
const resp = await this.fetchFunction(url, { const resp = await this.fetchFunction(url, {
headers: this.token ? { Authorization: `token ${this.token}` } : {}, headers: this.token ? { Authorization: `token ${this.token}` } : {},
}); });
if (!resp.ok) { if (!resp.ok) {
console.error(`Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText}`); console.error(`Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
return taggedShas; return { shas: taggedShas, map: tagNameBySha };
} }
const data: plugins.interfaces.ITag[] = await resp.json(); const data: plugins.interfaces.ITag[] = await resp.json();
if (data.length === 0) break; if (data.length === 0) break;
for (const t of data) { for (const t of data) {
if (t.commit?.sha) taggedShas.add(t.commit.sha); const sha = t.commit?.sha;
if (sha) {
taggedShas.add(sha);
if (t.name) tagNameBySha.set(sha, t.name);
}
} }
if (data.length < 50) break; if (data.length < this.pageLimit) break;
page++; page++;
} }
return taggedShas; return { shas: taggedShas, map: tagNameBySha };
} }
private async fetchAllOrganizations(): Promise<string[]> { private async fetchAllOrganizations(): Promise<string[]> {
const resp = await this.fetchFunction('/api/v1/orgs', { const headers = this.token ? { Authorization: `token ${this.token}` } : {};
headers: this.token ? { Authorization: `token ${this.token}` } : {}, let page = 1;
}); const orgs: string[] = [];
if (!resp.ok) { while (true) {
throw new Error(`Failed to fetch organizations: ${resp.statusText}`); const resp = await this.fetchFunction(`/api/v1/orgs?limit=${this.pageLimit}&page=${page}`, { headers });
if (!resp.ok) {
throw new Error(`Failed to fetch organizations: ${resp.status} ${resp.statusText}`);
}
const data: { username: string }[] = await resp.json();
if (data.length === 0) break;
orgs.push(...data.map((o) => o.username));
if (data.length < this.pageLimit) break;
page++;
} }
const data: { username: string }[] = await resp.json(); return orgs;
return data.map((o) => o.username);
} }
private async fetchRepositoriesForOrg(org: string): Promise<plugins.interfaces.IRepository[]> { private async fetchRepositoriesForOrg(org: string): Promise<plugins.interfaces.IRepository[]> {
const resp = await this.fetchFunction(`/api/v1/orgs/${org}/repos?limit=50`, { const headers = this.token ? { Authorization: `token ${this.token}` } : {};
headers: this.token ? { Authorization: `token ${this.token}` } : {}, let page = 1;
}); const repos: plugins.interfaces.IRepository[] = [];
if (!resp.ok) { while (true) {
throw new Error(`Failed to fetch repositories for ${org}: ${resp.statusText}`); const resp = await this.fetchFunction(`/api/v1/orgs/${org}/repos?limit=${this.pageLimit}&page=${page}`, { headers });
if (!resp.ok) {
throw new Error(`Failed to fetch repositories for ${org}: ${resp.status} ${resp.statusText}`);
}
const data: plugins.interfaces.IRepository[] = await resp.json();
if (data.length === 0) break;
repos.push(...data);
if (data.length < this.pageLimit) break;
page++;
} }
const data: plugins.interfaces.IRepository[] = await resp.json(); return repos;
return data;
} }
private async fetchRecentCommitsForRepo( private async fetchRecentCommitsForRepo(
@@ -308,23 +364,52 @@ export class CodeFeed {
sinceTimestamp?: string sinceTimestamp?: string
): Promise<plugins.interfaces.ICommit[]> { ): Promise<plugins.interfaces.ICommit[]> {
const since = sinceTimestamp ?? this.lastRunTimestamp; const since = sinceTimestamp ?? this.lastRunTimestamp;
const resp = await this.fetchFunction( const headers = this.token ? { Authorization: `token ${this.token}` } : {};
`/api/v1/repos/${owner}/${repo}/commits?since=${encodeURIComponent( let page = 1;
since const commits: plugins.interfaces.ICommit[] = [];
)}&limit=50`, while (true) {
{ headers: this.token ? { Authorization: `token ${this.token}` } : {} } const url = `/api/v1/repos/${owner}/${repo}/commits?since=${encodeURIComponent(since)}&limit=${this.pageLimit}&page=${page}`;
); const resp = await this.fetchFunction(url, { headers });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`Failed to fetch commits for ${owner}/${repo}: ${resp.statusText}`); throw new Error(`Failed to fetch commits for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
}
const data: plugins.interfaces.ICommit[] = await resp.json();
if (data.length === 0) break;
commits.push(...data);
if (data.length < this.pageLimit) break;
page++;
} }
const data: plugins.interfaces.ICommit[] = await resp.json(); return commits;
return data;
} }
public async fetchFunction( public async fetchFunction(
urlArg: string, urlArg: string,
optionsArg: RequestInit = {} optionsArg: RequestInit = {}
): Promise<Response> { ): Promise<Response> {
return fetch(`${this.baseUrl}${urlArg}`, optionsArg); const maxAttempts = 4;
let attempt = 0;
let lastError: any;
while (attempt < maxAttempts) {
try {
const resp = await fetch(`${this.baseUrl}${urlArg}`, optionsArg);
// retry on 429 and 5xx
if (resp.status === 429 || resp.status >= 500) {
const retryAfter = Number(resp.headers.get('retry-after'));
const backoffMs = retryAfter
? retryAfter * 1000
: Math.min(32000, 1000 * Math.pow(2, attempt)) + Math.floor(Math.random() * 250);
await new Promise((r) => setTimeout(r, backoffMs));
attempt++;
continue;
}
return resp;
} catch (e: any) {
lastError = e;
const backoffMs = Math.min(32000, 1000 * Math.pow(2, attempt)) + Math.floor(Math.random() * 250);
await new Promise((r) => setTimeout(r, backoffMs));
attempt++;
}
}
throw new Error(`fetchFunction failed after retries for ${urlArg}: ${lastError?.message ?? 'unknown error'}`);
} }
} }

View File

@@ -22,6 +22,7 @@ export interface ICommit {
} }
export interface ITag { export interface ITag {
name?: string;
commit?: { commit?: {
sha?: string; sha?: string;
}; };