@dependabit/github-client 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/auth/basic.d.ts +46 -0
- package/dist/auth/basic.d.ts.map +1 -0
- package/dist/auth/basic.js +88 -0
- package/dist/auth/basic.js.map +1 -0
- package/dist/auth/oauth.d.ts +48 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +139 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/token.d.ts +40 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +67 -0
- package/dist/auth/token.js.map +1 -0
- package/dist/auth.d.ts +47 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +78 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +74 -0
- package/dist/client.js.map +1 -0
- package/dist/commits.d.ts +57 -0
- package/dist/commits.d.ts.map +1 -0
- package/dist/commits.js +113 -0
- package/dist/commits.js.map +1 -0
- package/dist/feedback.d.ts +69 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +111 -0
- package/dist/feedback.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/issues.d.ts +55 -0
- package/dist/issues.d.ts.map +1 -0
- package/dist/issues.js +123 -0
- package/dist/issues.js.map +1 -0
- package/dist/rate-limit.d.ts +71 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +145 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/releases.d.ts +50 -0
- package/dist/releases.d.ts.map +1 -0
- package/dist/releases.js +113 -0
- package/dist/releases.js.map +1 -0
- package/package.json +39 -0
- package/src/auth/basic.ts +102 -0
- package/src/auth/oauth.ts +183 -0
- package/src/auth/token.ts +81 -0
- package/src/auth.ts +100 -0
- package/src/client.test.ts +115 -0
- package/src/client.ts +109 -0
- package/src/commits.ts +184 -0
- package/src/feedback.ts +166 -0
- package/src/index.ts +15 -0
- package/src/issues.ts +185 -0
- package/src/rate-limit.ts +210 -0
- package/src/releases.ts +149 -0
- package/test/auth/basic.test.ts +122 -0
- package/test/auth/oauth.test.ts +196 -0
- package/test/auth/token.test.ts +97 -0
- package/test/commits.test.ts +169 -0
- package/test/feedback.test.ts +203 -0
- package/test/issues.test.ts +197 -0
- package/test/rate-limit.test.ts +154 -0
- package/test/releases.test.ts +187 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/issues.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Manager
|
|
3
|
+
* Handles GitHub issue creation and management for dependency changes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Octokit } from 'octokit';
|
|
7
|
+
|
|
8
|
+
export interface IssueData {
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
severity: 'breaking' | 'major' | 'minor';
|
|
14
|
+
dependency: {
|
|
15
|
+
id: string;
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
assignee?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IssueResult {
|
|
22
|
+
number: number;
|
|
23
|
+
url: string;
|
|
24
|
+
labels: string[];
|
|
25
|
+
assignees?: string[] | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UpdateIssueData {
|
|
29
|
+
owner: string;
|
|
30
|
+
repo: string;
|
|
31
|
+
issueNumber: number;
|
|
32
|
+
body: string;
|
|
33
|
+
severity?: 'breaking' | 'major' | 'minor';
|
|
34
|
+
append?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class IssueManager {
|
|
38
|
+
private octokit: Octokit;
|
|
39
|
+
|
|
40
|
+
constructor(auth?: string) {
|
|
41
|
+
this.octokit = new Octokit({
|
|
42
|
+
auth: auth || process.env['GITHUB_TOKEN']
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new issue for a dependency change
|
|
48
|
+
*/
|
|
49
|
+
async createIssue(data: IssueData): Promise<IssueResult> {
|
|
50
|
+
const { owner, repo, title, body, severity, dependency, assignee } = data;
|
|
51
|
+
|
|
52
|
+
// Prepare labels
|
|
53
|
+
const labels = ['dependabit', `severity:${severity}`, 'dependency-update'];
|
|
54
|
+
|
|
55
|
+
// Create the issue
|
|
56
|
+
const response = await this.octokit.rest.issues.create({
|
|
57
|
+
owner,
|
|
58
|
+
repo,
|
|
59
|
+
title,
|
|
60
|
+
body: this.formatIssueBody(body, dependency),
|
|
61
|
+
labels,
|
|
62
|
+
...(assignee && { assignees: [assignee] })
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
number: response.data.number,
|
|
67
|
+
url: response.data.html_url,
|
|
68
|
+
labels,
|
|
69
|
+
...(assignee && { assignees: [assignee] })
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Finds an existing issue for a dependency
|
|
75
|
+
*/
|
|
76
|
+
async findExistingIssue(params: {
|
|
77
|
+
owner: string;
|
|
78
|
+
repo: string;
|
|
79
|
+
dependencyId: string;
|
|
80
|
+
}): Promise<IssueResult | null> {
|
|
81
|
+
const { owner, repo, dependencyId } = params;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Search for open issues with dependabit label and dependency ID
|
|
85
|
+
const query = `repo:${owner}/${repo} is:issue is:open label:dependabit ${dependencyId}`;
|
|
86
|
+
|
|
87
|
+
const response = await this.octokit.rest.search.issuesAndPullRequests({
|
|
88
|
+
q: query,
|
|
89
|
+
per_page: 1
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (response.data.items.length === 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const issue = response.data.items[0];
|
|
97
|
+
if (!issue) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
number: issue.number,
|
|
103
|
+
url: issue.html_url,
|
|
104
|
+
labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || ''))
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error finding existing issue:', error);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Updates an existing issue
|
|
114
|
+
*/
|
|
115
|
+
async updateIssue(data: UpdateIssueData): Promise<IssueResult> {
|
|
116
|
+
const { owner, repo, issueNumber, body, severity, append } = data;
|
|
117
|
+
|
|
118
|
+
let finalBody = body;
|
|
119
|
+
|
|
120
|
+
// If appending, fetch current body first
|
|
121
|
+
if (append) {
|
|
122
|
+
const current = await this.octokit.rest.issues.get({
|
|
123
|
+
owner,
|
|
124
|
+
repo,
|
|
125
|
+
issue_number: issueNumber
|
|
126
|
+
});
|
|
127
|
+
finalBody = `${current.data.body}\n\n---\n\n${body}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Update labels if severity changed
|
|
131
|
+
const updateParams: {
|
|
132
|
+
owner: string;
|
|
133
|
+
repo: string;
|
|
134
|
+
issue_number: number;
|
|
135
|
+
body: string;
|
|
136
|
+
labels?: string[];
|
|
137
|
+
} = {
|
|
138
|
+
owner,
|
|
139
|
+
repo,
|
|
140
|
+
issue_number: issueNumber,
|
|
141
|
+
body: finalBody
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (severity) {
|
|
145
|
+
const current = await this.octokit.rest.issues.get({
|
|
146
|
+
owner,
|
|
147
|
+
repo,
|
|
148
|
+
issue_number: issueNumber
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const existingLabels = current.data.labels
|
|
152
|
+
.map((l) => (typeof l === 'string' ? l : l.name))
|
|
153
|
+
.filter((l): l is string => !!l);
|
|
154
|
+
|
|
155
|
+
const severityLabels = ['dependabit', `severity:${severity}`, 'dependency-update'];
|
|
156
|
+
|
|
157
|
+
const mergedLabels = Array.from(new Set([...existingLabels, ...severityLabels]));
|
|
158
|
+
|
|
159
|
+
updateParams.labels = mergedLabels;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const response = await this.octokit.rest.issues.update(updateParams);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
number: response.data.number,
|
|
166
|
+
url: response.data.html_url,
|
|
167
|
+
labels: response.data.labels.map((l) => (typeof l === 'string' ? l : l.name || ''))
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Formats the issue body with dependency metadata
|
|
173
|
+
*/
|
|
174
|
+
private formatIssueBody(body: string, dependency: { id: string; url: string }): string {
|
|
175
|
+
return `${body}
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
**Dependency Information**
|
|
180
|
+
- ID: \`${dependency.id}\`
|
|
181
|
+
- URL: ${dependency.url}
|
|
182
|
+
|
|
183
|
+
*This issue was automatically created by dependabit. Add \`false-positive\` or \`true-positive\` label to provide feedback.*`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limit Handler
|
|
3
|
+
* Manages GitHub API rate limits and request budgeting
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Octokit } from 'octokit';
|
|
7
|
+
|
|
8
|
+
export interface RateLimitInfo {
|
|
9
|
+
limit: number;
|
|
10
|
+
remaining: number;
|
|
11
|
+
reset: Date;
|
|
12
|
+
used: number;
|
|
13
|
+
warning?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RateLimitStatus {
|
|
17
|
+
core: RateLimitInfo & { percentageRemaining: number };
|
|
18
|
+
search: RateLimitInfo & { percentageRemaining: number };
|
|
19
|
+
graphql: RateLimitInfo & { percentageRemaining: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BudgetReservation {
|
|
23
|
+
reserved: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
waitTime?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RateLimitHandler {
|
|
29
|
+
private octokit: Octokit;
|
|
30
|
+
private lastCheck?: RateLimitStatus;
|
|
31
|
+
private lastCheckTime?: Date;
|
|
32
|
+
|
|
33
|
+
constructor(auth?: string) {
|
|
34
|
+
this.octokit = new Octokit({
|
|
35
|
+
auth: auth || process.env['GITHUB_TOKEN']
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks current rate limit status
|
|
41
|
+
*/
|
|
42
|
+
async checkRateLimit(): Promise<RateLimitInfo> {
|
|
43
|
+
const response = await this.octokit.rest.rateLimit.get();
|
|
44
|
+
const { rate } = response.data;
|
|
45
|
+
|
|
46
|
+
const info: RateLimitInfo = {
|
|
47
|
+
limit: rate.limit,
|
|
48
|
+
remaining: rate.remaining,
|
|
49
|
+
reset: new Date(rate.reset * 1000),
|
|
50
|
+
used: rate.used
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Add warning if approaching limit
|
|
54
|
+
if (info.remaining < info.limit * 0.1) {
|
|
55
|
+
info.warning = `Only ${info.remaining} requests remaining. Reset at ${info.reset.toISOString()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return info;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Waits if rate limited
|
|
63
|
+
*/
|
|
64
|
+
async waitIfNeeded(): Promise<void> {
|
|
65
|
+
const rateLimit = await this.checkRateLimit();
|
|
66
|
+
|
|
67
|
+
if (rateLimit.remaining === 0) {
|
|
68
|
+
const waitTime = this.calculateWaitTime(rateLimit);
|
|
69
|
+
if (waitTime > 0) {
|
|
70
|
+
console.log(`Rate limited. Waiting ${Math.ceil(waitTime / 1000)} seconds until reset...`);
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Calculates wait time until rate limit resets
|
|
78
|
+
*/
|
|
79
|
+
calculateWaitTime(rateLimitInfo: RateLimitInfo): number {
|
|
80
|
+
if (rateLimitInfo.remaining > 0) {
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const resetTime = rateLimitInfo.reset.getTime();
|
|
86
|
+
const waitTime = Math.max(0, resetTime - now);
|
|
87
|
+
|
|
88
|
+
return waitTime;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attempts to reserve API call budget with proactive checking
|
|
93
|
+
*/
|
|
94
|
+
async reserveBudget(
|
|
95
|
+
callsNeeded: number,
|
|
96
|
+
options?: {
|
|
97
|
+
safetyMargin?: number; // Additional buffer (default: 10% of calls needed)
|
|
98
|
+
maxWaitTime?: number; // Max time to wait in ms
|
|
99
|
+
}
|
|
100
|
+
): Promise<BudgetReservation> {
|
|
101
|
+
const safetyMargin = options?.safetyMargin ?? Math.ceil(callsNeeded * 0.1);
|
|
102
|
+
const totalNeeded = callsNeeded + safetyMargin;
|
|
103
|
+
|
|
104
|
+
const rateLimit = await this.checkRateLimit();
|
|
105
|
+
|
|
106
|
+
if (rateLimit.remaining >= totalNeeded) {
|
|
107
|
+
return {
|
|
108
|
+
reserved: true
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const waitTime = this.calculateWaitTime(rateLimit);
|
|
113
|
+
|
|
114
|
+
// Check if wait time exceeds maximum allowed
|
|
115
|
+
if (options?.maxWaitTime && waitTime > options.maxWaitTime) {
|
|
116
|
+
return {
|
|
117
|
+
reserved: false,
|
|
118
|
+
reason: `Wait time (${Math.ceil(waitTime / 1000)}s) exceeds maximum (${Math.ceil(options.maxWaitTime / 1000)}s)`,
|
|
119
|
+
waitTime
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
reserved: false,
|
|
125
|
+
reason: `Insufficient API quota. Need ${callsNeeded} + ${safetyMargin} margin, have ${rateLimit.remaining}`,
|
|
126
|
+
waitTime
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Proactively check if operation can proceed without hitting rate limit
|
|
132
|
+
*/
|
|
133
|
+
async canProceed(
|
|
134
|
+
estimatedCalls: number,
|
|
135
|
+
options?: {
|
|
136
|
+
threshold?: number; // Minimum remaining calls (default: 100)
|
|
137
|
+
safetyMargin?: number;
|
|
138
|
+
}
|
|
139
|
+
): Promise<{ canProceed: boolean; reason?: string }> {
|
|
140
|
+
const threshold = options?.threshold ?? 100;
|
|
141
|
+
const safetyMargin = options?.safetyMargin ?? Math.ceil(estimatedCalls * 0.1);
|
|
142
|
+
const totalNeeded = estimatedCalls + safetyMargin;
|
|
143
|
+
|
|
144
|
+
const rateLimit = await this.checkRateLimit();
|
|
145
|
+
|
|
146
|
+
// Check if we have enough remaining calls
|
|
147
|
+
if (rateLimit.remaining < totalNeeded) {
|
|
148
|
+
return {
|
|
149
|
+
canProceed: false,
|
|
150
|
+
reason: `Insufficient quota: need ${totalNeeded}, have ${rateLimit.remaining}`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check if we'd drop below threshold
|
|
155
|
+
if (rateLimit.remaining - totalNeeded < threshold) {
|
|
156
|
+
return {
|
|
157
|
+
canProceed: false,
|
|
158
|
+
reason: `Operation would leave only ${rateLimit.remaining - totalNeeded} calls (threshold: ${threshold})`
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { canProceed: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gets detailed rate limit status for all API categories
|
|
167
|
+
*/
|
|
168
|
+
async getRateLimitStatus(): Promise<RateLimitStatus> {
|
|
169
|
+
const response = await this.octokit.rest.rateLimit.get();
|
|
170
|
+
const { resources } = response.data;
|
|
171
|
+
|
|
172
|
+
const createInfo = (resource: {
|
|
173
|
+
limit: number;
|
|
174
|
+
remaining: number;
|
|
175
|
+
reset: number;
|
|
176
|
+
used: number;
|
|
177
|
+
}): RateLimitInfo & { percentageRemaining: number } => ({
|
|
178
|
+
limit: resource.limit,
|
|
179
|
+
remaining: resource.remaining,
|
|
180
|
+
reset: new Date(resource.reset * 1000),
|
|
181
|
+
used: resource.used,
|
|
182
|
+
percentageRemaining: resource.limit > 0 ? (resource.remaining / resource.limit) * 100 : 0
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const status: RateLimitStatus = {
|
|
186
|
+
core: createInfo(resources.core),
|
|
187
|
+
search: createInfo(resources.search),
|
|
188
|
+
graphql: createInfo(resources.graphql || { limit: 0, remaining: 0, reset: 0, used: 0 })
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
this.lastCheck = status;
|
|
192
|
+
this.lastCheckTime = new Date();
|
|
193
|
+
|
|
194
|
+
return status;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Gets cached rate limit status (avoids API call)
|
|
199
|
+
*/
|
|
200
|
+
getCachedStatus(): RateLimitStatus | undefined {
|
|
201
|
+
// Return cached status if less than 60 seconds old
|
|
202
|
+
if (this.lastCheck && this.lastCheckTime) {
|
|
203
|
+
const age = Date.now() - this.lastCheckTime.getTime();
|
|
204
|
+
if (age < 60000) {
|
|
205
|
+
return this.lastCheck;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/releases.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release Manager
|
|
3
|
+
* Handles fetching and comparing GitHub releases
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Octokit } from 'octokit';
|
|
7
|
+
|
|
8
|
+
export interface Release {
|
|
9
|
+
tagName: string;
|
|
10
|
+
name: string;
|
|
11
|
+
publishedAt: Date;
|
|
12
|
+
body?: string | undefined;
|
|
13
|
+
htmlUrl: string;
|
|
14
|
+
prerelease?: boolean;
|
|
15
|
+
draft?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReleaseComparison {
|
|
19
|
+
newReleases: Release[];
|
|
20
|
+
oldReleases: Release[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ReleaseManager {
|
|
24
|
+
private octokit: Octokit;
|
|
25
|
+
|
|
26
|
+
constructor(auth?: string) {
|
|
27
|
+
this.octokit = new Octokit({
|
|
28
|
+
auth: auth || process.env['GITHUB_TOKEN']
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fetches the latest release from a repository
|
|
34
|
+
*/
|
|
35
|
+
async getLatestRelease(params: { owner: string; repo: string }): Promise<Release | null> {
|
|
36
|
+
const { owner, repo } = params;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.octokit.rest.repos.getLatestRelease({
|
|
40
|
+
owner,
|
|
41
|
+
repo
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
tagName: response.data.tag_name,
|
|
46
|
+
name: response.data.name || response.data.tag_name,
|
|
47
|
+
publishedAt: new Date(response.data.published_at || response.data.created_at),
|
|
48
|
+
body: response.data.body || undefined,
|
|
49
|
+
htmlUrl: response.data.html_url,
|
|
50
|
+
prerelease: response.data.prerelease,
|
|
51
|
+
draft: response.data.draft
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as { status?: number }).status === 404) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetches all releases from a repository
|
|
63
|
+
*/
|
|
64
|
+
async getAllReleases(params: {
|
|
65
|
+
owner: string;
|
|
66
|
+
repo: string;
|
|
67
|
+
page?: number;
|
|
68
|
+
perPage?: number;
|
|
69
|
+
}): Promise<Release[]> {
|
|
70
|
+
const { owner, repo, page = 1, perPage = 30 } = params;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await this.octokit.rest.repos.listReleases({
|
|
74
|
+
owner,
|
|
75
|
+
repo,
|
|
76
|
+
page,
|
|
77
|
+
per_page: perPage
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return response.data.map((release) => ({
|
|
81
|
+
tagName: release.tag_name,
|
|
82
|
+
name: release.name || release.tag_name,
|
|
83
|
+
publishedAt: new Date(release.published_at || release.created_at),
|
|
84
|
+
body: release.body || undefined,
|
|
85
|
+
htmlUrl: release.html_url,
|
|
86
|
+
prerelease: release.prerelease,
|
|
87
|
+
draft: release.draft
|
|
88
|
+
}));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as { status?: number }).status === 404) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compares two sets of releases to find new ones
|
|
99
|
+
*/
|
|
100
|
+
compareReleases(oldReleases: Release[], newReleases: Release[]): ReleaseComparison {
|
|
101
|
+
const oldTags = new Set(oldReleases.map((r) => r.tagName));
|
|
102
|
+
const newTags = new Set(newReleases.map((r) => r.tagName));
|
|
103
|
+
|
|
104
|
+
// Find releases in new but not in old
|
|
105
|
+
const newOnes = newReleases.filter((r) => !oldTags.has(r.tagName));
|
|
106
|
+
|
|
107
|
+
// Find releases in old but not in new (removed/deleted)
|
|
108
|
+
const oldOnes = oldReleases.filter((r) => !newTags.has(r.tagName));
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
newReleases: newOnes,
|
|
112
|
+
oldReleases: oldOnes
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fetches release notes for a specific tag
|
|
118
|
+
*/
|
|
119
|
+
async getReleaseByTag(params: {
|
|
120
|
+
owner: string;
|
|
121
|
+
repo: string;
|
|
122
|
+
tag: string;
|
|
123
|
+
}): Promise<Release | null> {
|
|
124
|
+
const { owner, repo, tag } = params;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const response = await this.octokit.rest.repos.getReleaseByTag({
|
|
128
|
+
owner,
|
|
129
|
+
repo,
|
|
130
|
+
tag
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
tagName: response.data.tag_name,
|
|
135
|
+
name: response.data.name || response.data.tag_name,
|
|
136
|
+
publishedAt: new Date(response.data.published_at || response.data.created_at),
|
|
137
|
+
body: response.data.body || undefined,
|
|
138
|
+
htmlUrl: response.data.html_url,
|
|
139
|
+
prerelease: response.data.prerelease,
|
|
140
|
+
draft: response.data.draft
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if ((error as { status?: number }).status === 404) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { BasicAuthHandler } from '../../src/auth/basic';
|
|
3
|
+
|
|
4
|
+
describe('BasicAuthHandler', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.clearAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('constructor', () => {
|
|
10
|
+
it('should create handler with username and password', () => {
|
|
11
|
+
const handler = new BasicAuthHandler('user', 'pass');
|
|
12
|
+
expect(handler).toBeInstanceOf(BasicAuthHandler);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should throw error for empty username', () => {
|
|
16
|
+
expect(() => new BasicAuthHandler('', 'pass')).toThrow('Username cannot be empty');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should throw error for empty password', () => {
|
|
20
|
+
expect(() => new BasicAuthHandler('user', '')).toThrow('Password cannot be empty');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('authenticate', () => {
|
|
25
|
+
it('should return auth object with credentials', async () => {
|
|
26
|
+
const handler = new BasicAuthHandler('testuser', 'testpass');
|
|
27
|
+
const auth = await handler.authenticate();
|
|
28
|
+
|
|
29
|
+
expect(auth).toEqual({
|
|
30
|
+
type: 'basic',
|
|
31
|
+
username: 'testuser',
|
|
32
|
+
password: 'testpass'
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should encode credentials properly', async () => {
|
|
37
|
+
const handler = new BasicAuthHandler('user@example.com', 'p@ssw0rd!');
|
|
38
|
+
const auth = await handler.authenticate();
|
|
39
|
+
|
|
40
|
+
expect(auth.username).toBe('user@example.com');
|
|
41
|
+
expect(auth.password).toBe('p@ssw0rd!');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getAuthHeader', () => {
|
|
46
|
+
it('should generate base64 encoded auth header', () => {
|
|
47
|
+
const handler = new BasicAuthHandler('user', 'pass');
|
|
48
|
+
const header = handler.getAuthHeader();
|
|
49
|
+
|
|
50
|
+
// "user:pass" in base64 is "dXNlcjpwYXNz"
|
|
51
|
+
expect(header).toBe('Basic dXNlcjpwYXNz');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should handle special characters in credentials', () => {
|
|
55
|
+
const handler = new BasicAuthHandler('user@example.com', 'p@ss:word');
|
|
56
|
+
const header = handler.getAuthHeader();
|
|
57
|
+
|
|
58
|
+
expect(header).toMatch(/^Basic [A-Za-z0-9+/=]+$/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('validate', () => {
|
|
63
|
+
it('should validate credentials format', () => {
|
|
64
|
+
const handler = new BasicAuthHandler('user', 'pass');
|
|
65
|
+
expect(handler.validate()).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should reject username with invalid characters', () => {
|
|
69
|
+
const handler = new BasicAuthHandler('user\n', 'pass');
|
|
70
|
+
expect(handler.validate()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should reject password with newline', () => {
|
|
74
|
+
const handler = new BasicAuthHandler('user', 'pass\n');
|
|
75
|
+
expect(handler.validate()).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should accept email as username', () => {
|
|
79
|
+
const handler = new BasicAuthHandler('user@example.com', 'pass');
|
|
80
|
+
expect(handler.validate()).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('getType', () => {
|
|
85
|
+
it('should return basic type', () => {
|
|
86
|
+
const handler = new BasicAuthHandler('user', 'pass');
|
|
87
|
+
expect(handler.getType()).toBe('basic');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('credential rotation', () => {
|
|
92
|
+
it('should allow password update', () => {
|
|
93
|
+
const handler = new BasicAuthHandler('user', 'oldpass');
|
|
94
|
+
handler.updateCredentials('user', 'newpass');
|
|
95
|
+
|
|
96
|
+
const header = handler.getAuthHeader();
|
|
97
|
+
expect(header).toContain(Buffer.from('user:newpass').toString('base64'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should throw error on empty password update', () => {
|
|
101
|
+
const handler = new BasicAuthHandler('user', 'pass');
|
|
102
|
+
expect(() => handler.updateCredentials('user', '')).toThrow('Password cannot be empty');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('security', () => {
|
|
107
|
+
it('should not expose password in toString', () => {
|
|
108
|
+
const handler = new BasicAuthHandler('user', 'secretpass');
|
|
109
|
+
const str = handler.toString();
|
|
110
|
+
|
|
111
|
+
expect(str).not.toContain('secretpass');
|
|
112
|
+
expect(str).toContain('***');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should not expose password in JSON', () => {
|
|
116
|
+
const handler = new BasicAuthHandler('user', 'secretpass');
|
|
117
|
+
const json = JSON.stringify(handler);
|
|
118
|
+
|
|
119
|
+
expect(json).not.toContain('secretpass');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|