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:
12
package.json
12
package.json
@@ -16,17 +16,17 @@
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsbundle": "^2.2.5",
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tsrun": "^1.2.46",
|
||||
"@git.zone/tstest": "^1.0.96",
|
||||
"@push.rocks/tapbundle": "^5.6.3",
|
||||
"@git.zone/tstest": "^2.3.8",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.15.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartnpm": "^2.0.4",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartnpm": "^2.0.6",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartxml": "^1.1.1"
|
||||
},
|
||||
|
5808
pnpm-lock.yaml
generated
5808
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
207
ts/index.ts
207
ts/index.ts
@@ -4,6 +4,7 @@ export class CodeFeed {
|
||||
private baseUrl: string;
|
||||
private token?: string;
|
||||
private lastRunTimestamp: string;
|
||||
private pageLimit = 50;
|
||||
// Raw changelog content for the current repository
|
||||
private changelogContent: string = '';
|
||||
// npm registry helper for published-on-npm checks
|
||||
@@ -16,6 +17,9 @@ export class CodeFeed {
|
||||
private enableNpmCheck: boolean = true;
|
||||
// return only tagged commits (false by default)
|
||||
private enableTaggedOnly: boolean = false;
|
||||
// allow/deny filters
|
||||
private orgAllowlist?: string[];
|
||||
private orgDenylist?: string[];
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@@ -26,6 +30,8 @@ export class CodeFeed {
|
||||
cacheWindowMs?: number;
|
||||
enableNpmCheck?: boolean;
|
||||
taggedOnly?: boolean;
|
||||
orgAllowlist?: string[];
|
||||
orgDenylist?: string[];
|
||||
}
|
||||
) {
|
||||
this.baseUrl = baseUrl;
|
||||
@@ -37,6 +43,8 @@ export class CodeFeed {
|
||||
this.cacheWindowMs = options?.cacheWindowMs;
|
||||
this.enableNpmCheck = options?.enableNpmCheck ?? true;
|
||||
this.enableTaggedOnly = options?.taggedOnly ?? false;
|
||||
this.orgAllowlist = options?.orgAllowlist;
|
||||
this.orgDenylist = options?.orgDenylist;
|
||||
this.cache = [];
|
||||
// npm registry instance for version lookups
|
||||
this.npmRegistry = new plugins.smartnpm.NpmRegistry();
|
||||
@@ -61,7 +69,14 @@ export class CodeFeed {
|
||||
}
|
||||
|
||||
// 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
|
||||
const repoLists = await Promise.all(
|
||||
@@ -122,11 +137,15 @@ export class CodeFeed {
|
||||
await this.loadChangelogFromRepo(owner, name);
|
||||
// fetch tags for this repo
|
||||
let taggedShas: Set<string>;
|
||||
let tagNameBySha: Map<string, string>;
|
||||
try {
|
||||
taggedShas = await this.fetchTags(owner, name);
|
||||
const tagInfo = await this.fetchTags(owner, name);
|
||||
taggedShas = tagInfo.shas;
|
||||
tagNameBySha = tagInfo.map;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to fetch tags for ${owner}/${name}:`, e.message);
|
||||
taggedShas = new Set<string>();
|
||||
tagNameBySha = new Map<string, string>();
|
||||
}
|
||||
// fetch npm package info only if any new commits correspond to a tag
|
||||
const hasTaggedCommit = commits.some((c) => taggedShas.has(c.sha));
|
||||
@@ -141,14 +160,23 @@ export class CodeFeed {
|
||||
}
|
||||
// build commit entries
|
||||
for (const c of commits) {
|
||||
const versionCandidate = c.commit.message.replace(/\n/g, '').trim();
|
||||
const isTagged = taggedShas.has(c.sha);
|
||||
const publishedOnNpm = isTagged && pkgInfo
|
||||
? pkgInfo.allVersions.some((v) => v.version === versionCandidate)
|
||||
// derive version from tag name if present (strip leading 'v')
|
||||
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;
|
||||
let changelogEntry: string | undefined;
|
||||
if (this.changelogContent) {
|
||||
changelogEntry = this.getChangelogForVersion(versionCandidate);
|
||||
if (versionFromTag) {
|
||||
changelogEntry = this.getChangelogForVersion(versionFromTag);
|
||||
}
|
||||
}
|
||||
newResults.push({
|
||||
baseUrl: this.baseUrl,
|
||||
@@ -187,40 +215,49 @@ export class CodeFeed {
|
||||
return this.cache;
|
||||
}
|
||||
// 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) {
|
||||
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.
|
||||
*/
|
||||
private async loadChangelogFromRepo(owner: string, repo: string): Promise<void> {
|
||||
const url = `/api/v1/repos/${owner}/${repo}/contents/changelog.md`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `token ${this.token}`;
|
||||
if (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
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
this.changelogContent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,49 +294,68 @@ export class CodeFeed {
|
||||
/**
|
||||
* 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 tagNameBySha = new Map<string, string>();
|
||||
let page = 1;
|
||||
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, {
|
||||
headers: this.token ? { Authorization: `token ${this.token}` } : {},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
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();
|
||||
if (data.length === 0) break;
|
||||
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++;
|
||||
}
|
||||
return taggedShas;
|
||||
return { shas: taggedShas, map: tagNameBySha };
|
||||
}
|
||||
|
||||
private async fetchAllOrganizations(): Promise<string[]> {
|
||||
const resp = await this.fetchFunction('/api/v1/orgs', {
|
||||
headers: this.token ? { Authorization: `token ${this.token}` } : {},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch organizations: ${resp.statusText}`);
|
||||
const headers = this.token ? { Authorization: `token ${this.token}` } : {};
|
||||
let page = 1;
|
||||
const orgs: string[] = [];
|
||||
while (true) {
|
||||
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 data.map((o) => o.username);
|
||||
return orgs;
|
||||
}
|
||||
|
||||
private async fetchRepositoriesForOrg(org: string): Promise<plugins.interfaces.IRepository[]> {
|
||||
const resp = await this.fetchFunction(`/api/v1/orgs/${org}/repos?limit=50`, {
|
||||
headers: this.token ? { Authorization: `token ${this.token}` } : {},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch repositories for ${org}: ${resp.statusText}`);
|
||||
const headers = this.token ? { Authorization: `token ${this.token}` } : {};
|
||||
let page = 1;
|
||||
const repos: plugins.interfaces.IRepository[] = [];
|
||||
while (true) {
|
||||
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 data;
|
||||
return repos;
|
||||
}
|
||||
|
||||
private async fetchRecentCommitsForRepo(
|
||||
@@ -308,23 +364,52 @@ export class CodeFeed {
|
||||
sinceTimestamp?: string
|
||||
): Promise<plugins.interfaces.ICommit[]> {
|
||||
const since = sinceTimestamp ?? this.lastRunTimestamp;
|
||||
const resp = await this.fetchFunction(
|
||||
`/api/v1/repos/${owner}/${repo}/commits?since=${encodeURIComponent(
|
||||
since
|
||||
)}&limit=50`,
|
||||
{ headers: this.token ? { Authorization: `token ${this.token}` } : {} }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch commits for ${owner}/${repo}: ${resp.statusText}`);
|
||||
const headers = this.token ? { Authorization: `token ${this.token}` } : {};
|
||||
let page = 1;
|
||||
const commits: plugins.interfaces.ICommit[] = [];
|
||||
while (true) {
|
||||
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) {
|
||||
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 data;
|
||||
return commits;
|
||||
}
|
||||
|
||||
public async fetchFunction(
|
||||
urlArg: string,
|
||||
optionsArg: RequestInit = {}
|
||||
): 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'}`);
|
||||
}
|
||||
}
|
@@ -22,6 +22,7 @@ export interface ICommit {
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
name?: string;
|
||||
commit?: {
|
||||
sha?: string;
|
||||
};
|
||||
|
Reference in New Issue
Block a user