mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-30 04:52:48 -04:00
Compare commits
3 Commits
github-iss
...
v0.70.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5474e199f | ||
|
|
db44848e2d | ||
|
|
9417ce3b3a |
5
.github/issue-resolution/package.json
vendored
5
.github/issue-resolution/package.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "issue-resolution",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
You are a GitHub issue resolution classifier.
|
||||
|
||||
Your job is to decide whether an open GitHub issue is:
|
||||
- AUTO_CLOSE
|
||||
- MANUAL_REVIEW
|
||||
- KEEP_OPEN
|
||||
|
||||
Rules:
|
||||
1. AUTO_CLOSE is only allowed if there is objective, hard evidence:
|
||||
- a merged linked PR that clearly resolves the issue, or
|
||||
- an explicit maintainer/member/owner/collaborator comment saying the issue is fixed, resolved, duplicate, or superseded
|
||||
2. If there is any contradictory later evidence, do NOT AUTO_CLOSE.
|
||||
3. If evidence is promising but not airtight, choose MANUAL_REVIEW.
|
||||
4. If the issue still appears active or unresolved, choose KEEP_OPEN.
|
||||
5. Do not invent evidence.
|
||||
6. Output valid JSON only.
|
||||
|
||||
Maintainer-authoritative roles:
|
||||
- MEMBER
|
||||
- OWNER
|
||||
- COLLABORATOR
|
||||
|
||||
Workarounds vs. actual fixes:
|
||||
- A WORKAROUND is when a user changes their own setup to avoid the problem (editing configs, using a different setting, manual SQL fixes, switching tools). Workarounds do NOT count as resolution — the underlying issue is still present in the product.
|
||||
- An ACTUAL FIX is when a user reports the problem went away after upgrading to a specific version (e.g., "fixed after updating to v0.65.1") or after a specific PR was merged. This suggests the fix was shipped in the product itself.
|
||||
- If only workarounds exist and no maintainer has confirmed a fix, classify as KEEP_OPEN.
|
||||
- If a user reports an actual fix via a version upgrade but no maintainer confirmed it, classify as MANUAL_REVIEW (not AUTO_CLOSE).
|
||||
|
||||
Important:
|
||||
- Later comments outweigh earlier ones.
|
||||
- A non-maintainer saying "fixed for me" is not enough for AUTO_CLOSE.
|
||||
- If uncertain, prefer MANUAL_REVIEW or KEEP_OPEN.
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"decision",
|
||||
"reason_code",
|
||||
"confidence",
|
||||
"hard_signals",
|
||||
"contradictions",
|
||||
"summary",
|
||||
"close_comment",
|
||||
"manual_review_note"
|
||||
],
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"enum": ["AUTO_CLOSE", "MANUAL_REVIEW", "KEEP_OPEN"]
|
||||
},
|
||||
"reason_code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"resolved_by_merged_pr",
|
||||
"maintainer_confirmed_resolved",
|
||||
"duplicate_confirmed",
|
||||
"superseded_confirmed",
|
||||
"likely_fixed_but_unconfirmed",
|
||||
"still_open",
|
||||
"unclear"
|
||||
]
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"hard_signals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"merged_pr",
|
||||
"maintainer_comment",
|
||||
"duplicate_reference",
|
||||
"superseded_reference"
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"contradictions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reporter_still_broken",
|
||||
"later_unresolved_comment",
|
||||
"ambiguous_pr_link",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": { "type": "string" },
|
||||
"close_comment": { "type": "string" },
|
||||
"manual_review_note": { "type": "string" }
|
||||
}
|
||||
}
|
||||
193
.github/issue-resolution/scripts/apply-decisions.mjs
vendored
193
.github/issue-resolution/scripts/apply-decisions.mjs
vendored
@@ -1,193 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const decisions = JSON.parse(await fs.readFile("decisions.json", "utf8"));
|
||||
const dryRun = String(process.env.DRY_RUN).toLowerCase() === "true";
|
||||
|
||||
const ghHeaders = {
|
||||
Authorization: `Bearer ${process.env.GH_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
|
||||
// Use PROJECT_PAT for project board operations, fall back to GH_TOKEN
|
||||
const projectHeaders = {
|
||||
Authorization: `Bearer ${process.env.PROJECT_PAT || process.env.GH_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
|
||||
async function rest(url, method = "GET", body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: ghHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${url}: ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function graphql(query, variables) {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: projectHeaders,
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
if (json.errors) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function addLabel(owner, repo, issueNumber, labels) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/labels`,
|
||||
"POST",
|
||||
{ labels }
|
||||
);
|
||||
}
|
||||
|
||||
async function addComment(owner, repo, issueNumber, body) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
|
||||
"POST",
|
||||
{ body }
|
||||
);
|
||||
}
|
||||
|
||||
async function closeIssue(owner, repo, issueNumber) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`,
|
||||
"PATCH",
|
||||
{ state: "closed", state_reason: "completed" }
|
||||
);
|
||||
}
|
||||
|
||||
async function getIssueNodeId(owner, repo, issueNumber) {
|
||||
const issue = await rest(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`);
|
||||
return issue.node_id;
|
||||
}
|
||||
|
||||
async function addToProject(issueNodeId) {
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await graphql(mutation, {
|
||||
projectId: process.env.PROJECT_ID,
|
||||
contentId: issueNodeId
|
||||
});
|
||||
return data.addProjectV2ItemById.item.id;
|
||||
} catch (err) {
|
||||
console.warn(`[WARN] Could not add to project (needs PAT with project scope): ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setTextField(itemId, fieldId, value) {
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId,
|
||||
itemId: $itemId,
|
||||
fieldId: $fieldId,
|
||||
value: { text: $value }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return graphql(mutation, {
|
||||
projectId: process.env.PROJECT_ID,
|
||||
itemId,
|
||||
fieldId,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
async function setNumberField(itemId, fieldId, value) {
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Float!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId,
|
||||
itemId: $itemId,
|
||||
fieldId: $fieldId,
|
||||
value: { number: $value }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return graphql(mutation, {
|
||||
projectId: process.env.PROJECT_ID,
|
||||
itemId,
|
||||
fieldId,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
async function addToProjectWithFields(owner, repo, d) {
|
||||
const issueNodeId = await getIssueNodeId(owner, repo, d.issue_number);
|
||||
const itemId = await addToProject(issueNodeId);
|
||||
|
||||
if (itemId) {
|
||||
if (process.env.PROJECT_CONFIDENCE_FIELD_ID) {
|
||||
await setNumberField(itemId, process.env.PROJECT_CONFIDENCE_FIELD_ID, d.model.confidence);
|
||||
}
|
||||
if (process.env.PROJECT_REASON_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_REASON_FIELD_ID, d.model.reason_code);
|
||||
}
|
||||
if (process.env.PROJECT_EVIDENCE_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_EVIDENCE_FIELD_ID, d.issue_url);
|
||||
}
|
||||
// Linked pull requests field is a built-in type that can't be set via API
|
||||
// GitHub auto-populates it from issue cross-references
|
||||
if (process.env.PROJECT_REPO_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_REPO_FIELD_ID, d.repository);
|
||||
}
|
||||
console.log(` → Added to project board`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of decisions) {
|
||||
const [owner, repo] = d.repository.split("/");
|
||||
|
||||
if (d.final_decision === "KEEP_OPEN") {
|
||||
console.log(`#${d.issue_number} → KEEP_OPEN (confidence: ${d.model.confidence}, reason: ${d.model.reason_code})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] #${d.issue_number} → ${d.final_decision} (confidence: ${d.model.confidence}, reason: ${d.model.reason_code})`);
|
||||
// In dry-run: populate project board but don't touch issues
|
||||
if (d.final_decision === "MANUAL_REVIEW" || d.final_decision === "AUTO_CLOSE") {
|
||||
await addToProjectWithFields(owner, repo, d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d.final_decision === "AUTO_CLOSE") {
|
||||
await addLabel(owner, repo, d.issue_number, ["auto-closed-resolved"]);
|
||||
await addComment(owner, repo, d.issue_number, d.model.close_comment);
|
||||
await closeIssue(owner, repo, d.issue_number);
|
||||
await addToProjectWithFields(owner, repo, d);
|
||||
}
|
||||
|
||||
if (d.final_decision === "MANUAL_REVIEW") {
|
||||
await addLabel(owner, repo, d.issue_number, ["resolution-candidate"]);
|
||||
await addToProjectWithFields(owner, repo, d);
|
||||
await addComment(
|
||||
owner,
|
||||
repo,
|
||||
d.issue_number,
|
||||
d.model.manual_review_note ||
|
||||
"This issue looks like a possible resolution candidate, but not with enough certainty for automatic closure. Added to the review queue."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const candidates = JSON.parse(await fs.readFile("candidates.json", "utf8"));
|
||||
const systemPrompt = await fs.readFile("prompts/issue-resolution-system.txt", "utf8");
|
||||
const outputSchema = JSON.parse(await fs.readFile("schemas/issue-resolution-output.json", "utf8"));
|
||||
|
||||
function isMaintainerRole(role) {
|
||||
return ["MEMBER", "OWNER", "COLLABORATOR"].includes(role || "");
|
||||
}
|
||||
|
||||
function preScore(candidate) {
|
||||
let score = 0;
|
||||
const hardSignals = [];
|
||||
const contradictions = [];
|
||||
|
||||
for (const t of candidate.timeline) {
|
||||
const sourceIssue = t.source?.issue;
|
||||
|
||||
if (t.event === "cross-referenced" && sourceIssue?.pull_request?.html_url) {
|
||||
hardSignals.push({
|
||||
type: "merged_pr",
|
||||
url: sourceIssue.html_url
|
||||
});
|
||||
score += 40; // provisional until PR merged state is verified
|
||||
}
|
||||
|
||||
if (["referenced", "connected"].includes(t.event)) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of candidate.comments) {
|
||||
const body = c.body.toLowerCase();
|
||||
|
||||
if (
|
||||
isMaintainerRole(c.author_association) &&
|
||||
/\b(fixed|resolved|duplicate|superseded|closing)\b/.test(body)
|
||||
) {
|
||||
score += 25;
|
||||
hardSignals.push({
|
||||
type: "maintainer_comment",
|
||||
url: c.html_url
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(still broken|still happening|not fixed|reproducible)\b/.test(body)) {
|
||||
score -= 50;
|
||||
contradictions.push({
|
||||
type: "later_unresolved_comment",
|
||||
url: c.html_url
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { score, hardSignals, contradictions };
|
||||
}
|
||||
|
||||
// GitHub Models gpt-4o has an 8000 token input limit.
|
||||
// Reserve ~2000 tokens for system prompt + response overhead.
|
||||
// 1 token ~= 4 chars, so cap user message at ~24000 chars.
|
||||
const MAX_USER_MESSAGE_CHARS = 24000;
|
||||
|
||||
function truncate(text, maxChars) {
|
||||
if (text.length <= maxChars) return text;
|
||||
return text.slice(0, maxChars) + "\n\n[... truncated due to length]";
|
||||
}
|
||||
|
||||
function buildUserMessage(candidate, pre) {
|
||||
const { issue, comments, timeline } = candidate;
|
||||
|
||||
const commentBlock = comments
|
||||
.map((c) => `[${c.author_association}] ${c.user} (${c.created_at}):\n${c.body}`)
|
||||
.join("\n---\n");
|
||||
|
||||
const timelineBlock = timeline
|
||||
.filter((t) => ["cross-referenced", "referenced", "connected", "closed", "reopened"].includes(t.event))
|
||||
.map((t) => {
|
||||
let line = `${t.event} (${t.created_at})`;
|
||||
if (t.source?.issue?.html_url) line += ` — ${t.source.issue.html_url}`;
|
||||
if (t.source?.issue?.pull_request?.html_url) line += ` (PR: ${t.source.issue.pull_request.html_url})`;
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const sections = [
|
||||
`## Issue #${issue.number}: ${issue.title}`,
|
||||
`URL: ${issue.html_url}`,
|
||||
`Created: ${issue.created_at} | Updated: ${issue.updated_at}`,
|
||||
`Labels: ${issue.labels.join(", ") || "none"}`,
|
||||
"",
|
||||
"### Body",
|
||||
truncate(issue.body || "(empty)", 4000),
|
||||
"",
|
||||
"### Comments",
|
||||
commentBlock || "(none)",
|
||||
"",
|
||||
"### Timeline events",
|
||||
timelineBlock || "(none)",
|
||||
];
|
||||
|
||||
if (candidate.linked_prs?.length) {
|
||||
sections.push("");
|
||||
sections.push("### Linked PRs (verified state)");
|
||||
for (const pr of candidate.linked_prs) {
|
||||
const status = pr.merged ? `MERGED (${pr.merged_at})` : pr.state.toUpperCase();
|
||||
sections.push(`- PR #${pr.number}: ${pr.title} — ${status} — ${pr.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (pre.hardSignals.length || pre.contradictions.length) {
|
||||
sections.push("");
|
||||
sections.push("### Automated evidence scan");
|
||||
for (const s of pre.hardSignals) {
|
||||
sections.push(`- SIGNAL: ${s.type} — ${s.url}`);
|
||||
}
|
||||
for (const c of pre.contradictions) {
|
||||
sections.push(`- CONTRADICTION: ${c.type} — ${c.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
return truncate(sections.join("\n"), MAX_USER_MESSAGE_CHARS);
|
||||
}
|
||||
|
||||
const MODEL = "gpt-4o-mini";
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function callGitHubModel(candidate, pre) {
|
||||
const body = JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: buildUserMessage(candidate, pre) },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "issue_resolution",
|
||||
strict: true,
|
||||
schema: outputSchema,
|
||||
},
|
||||
},
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
const res = await fetch("https://models.inference.ai.azure.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.GH_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers.get("retry-after")) || 30;
|
||||
if (retryAfter > 120) {
|
||||
console.warn(` [QUOTA EXHAUSTED] API wants ${retryAfter}s wait — skipping remaining issues.`);
|
||||
return null;
|
||||
}
|
||||
console.warn(` [RATE LIMITED] Waiting ${retryAfter}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`GitHub Models ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return JSON.parse(data.choices[0].message.content);
|
||||
}
|
||||
|
||||
throw new Error(`GitHub Models: exceeded ${MAX_RETRIES} retries due to rate limiting`);
|
||||
}
|
||||
|
||||
function enforcePolicy(modelOut, pre) {
|
||||
const approvedReasons = new Set([
|
||||
"resolved_by_merged_pr",
|
||||
"maintainer_confirmed_resolved",
|
||||
"duplicate_confirmed",
|
||||
"superseded_confirmed"
|
||||
]);
|
||||
|
||||
const hasHardSignal =
|
||||
(modelOut.hard_signals || []).some(s =>
|
||||
["merged_pr", "maintainer_comment", "duplicate_reference", "superseded_reference"].includes(s.type)
|
||||
) || pre.hardSignals.length > 0;
|
||||
|
||||
const hasContradiction =
|
||||
(modelOut.contradictions || []).length > 0 || pre.contradictions.length > 0;
|
||||
|
||||
// Only auto-close with very strict criteria
|
||||
if (
|
||||
modelOut.decision === "AUTO_CLOSE" &&
|
||||
modelOut.confidence >= 0.97 &&
|
||||
approvedReasons.has(modelOut.reason_code) &&
|
||||
hasHardSignal &&
|
||||
!hasContradiction
|
||||
) {
|
||||
return "AUTO_CLOSE";
|
||||
}
|
||||
|
||||
// Downgrade AUTO_CLOSE that didn't pass the gate
|
||||
if (modelOut.decision === "AUTO_CLOSE") {
|
||||
return "MANUAL_REVIEW";
|
||||
}
|
||||
|
||||
// Otherwise trust the model
|
||||
return modelOut.decision;
|
||||
}
|
||||
|
||||
console.log(`Classifying ${candidates.length} candidates with ${MODEL}...\n`);
|
||||
|
||||
// 15 req/min limit → 1 request every 4s. Use 4.5s for safety margin.
|
||||
const PACE_MS = 4500;
|
||||
let lastRequestTime = 0;
|
||||
|
||||
async function paced(fn) {
|
||||
const elapsed = Date.now() - lastRequestTime;
|
||||
if (elapsed < PACE_MS) await sleep(PACE_MS - elapsed);
|
||||
lastRequestTime = Date.now();
|
||||
return fn();
|
||||
}
|
||||
|
||||
const decisions = [];
|
||||
for (const candidate of candidates) {
|
||||
const pre = preScore(candidate);
|
||||
const modelOut = await paced(() => callGitHubModel(candidate, pre));
|
||||
|
||||
if (modelOut === null) {
|
||||
console.warn(`\nQuota exhausted after ${decisions.length} issues. Writing partial results.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalDecision = enforcePolicy(modelOut, pre);
|
||||
|
||||
decisions.push({
|
||||
repository: candidate.repository,
|
||||
issue_number: candidate.issue.number,
|
||||
issue_url: candidate.issue.html_url,
|
||||
title: candidate.issue.title,
|
||||
pre_score: pre.score,
|
||||
final_decision: finalDecision,
|
||||
model: modelOut
|
||||
});
|
||||
|
||||
console.log(
|
||||
`#${candidate.issue.number} | pre_score: ${pre.score} | model: ${modelOut.decision} @ ${modelOut.confidence} | final: ${finalDecision} | ${modelOut.reason_code}`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile("decisions.json", JSON.stringify(decisions, null, 2));
|
||||
console.log(`\nWrote ${decisions.length} decisions to decisions.json`);
|
||||
@@ -1,123 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const token = process.env.GH_TOKEN;
|
||||
const repo = process.env.REPO; // "owner/repo"
|
||||
const maxIssues = Number(process.env.MAX_ISSUES) || 100;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
|
||||
async function rest(url) {
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) throw new Error(`${res.status} ${url}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function restSafe(url) {
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function paginate(url, max) {
|
||||
const items = [];
|
||||
let page = 1;
|
||||
while (items.length < max) {
|
||||
const perPage = Math.min(100, max - items.length);
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
const batch = await rest(`${url}${sep}per_page=${perPage}&page=${page}`);
|
||||
if (!batch.length) break;
|
||||
items.push(...batch);
|
||||
page++;
|
||||
}
|
||||
return items.slice(0, max);
|
||||
}
|
||||
|
||||
console.log(`Fetching up to ${maxIssues} open issues from ${repo}...`);
|
||||
|
||||
const issues = await paginate(
|
||||
`https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=desc`,
|
||||
maxIssues
|
||||
);
|
||||
|
||||
// Filter out pull requests (GitHub API returns PRs as issues too)
|
||||
const realIssues = issues.filter((i) => !i.pull_request);
|
||||
console.log(`Found ${realIssues.length} open issues (excluded PRs).`);
|
||||
|
||||
const candidates = [];
|
||||
for (const issue of realIssues) {
|
||||
const [comments, timeline] = await Promise.all([
|
||||
rest(`https://api.github.com/repos/${repo}/issues/${issue.number}/comments?per_page=100`),
|
||||
rest(`https://api.github.com/repos/${repo}/issues/${issue.number}/timeline?per_page=100`),
|
||||
]);
|
||||
|
||||
candidates.push({
|
||||
repository: repo,
|
||||
issue: {
|
||||
number: issue.number,
|
||||
html_url: issue.html_url,
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
created_at: issue.created_at,
|
||||
updated_at: issue.updated_at,
|
||||
labels: issue.labels.map((l) => l.name),
|
||||
},
|
||||
comments: comments.map((c) => ({
|
||||
body: c.body,
|
||||
author_association: c.author_association,
|
||||
html_url: c.html_url,
|
||||
created_at: c.created_at,
|
||||
user: c.user?.login,
|
||||
})),
|
||||
timeline: timeline.map((t) => ({
|
||||
event: t.event,
|
||||
created_at: t.created_at,
|
||||
source: t.source
|
||||
? {
|
||||
issue: {
|
||||
html_url: t.source.issue?.html_url,
|
||||
pull_request: t.source.issue?.pull_request
|
||||
? { html_url: t.source.issue.pull_request.html_url }
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
linked_prs: [],
|
||||
});
|
||||
|
||||
// Fetch merge status for cross-referenced PRs
|
||||
const prUrls = new Set();
|
||||
for (const t of timeline) {
|
||||
const prHtml = t.source?.issue?.pull_request?.html_url;
|
||||
if (t.event === "cross-referenced" && prHtml) {
|
||||
prUrls.add(prHtml);
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = candidates[candidates.length - 1];
|
||||
for (const prHtml of prUrls) {
|
||||
// Extract owner/repo and PR number from URL like https://github.com/owner/repo/pull/123
|
||||
const match = prHtml.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
||||
if (!match) continue;
|
||||
const [, prRepo, prNum] = match;
|
||||
const pr = await restSafe(`https://api.github.com/repos/${prRepo}/pulls/${prNum}`);
|
||||
if (!pr) continue;
|
||||
candidate.linked_prs.push({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: prHtml,
|
||||
state: pr.state,
|
||||
merged: pr.merged || false,
|
||||
merged_at: pr.merged_at,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` #${issue.number} — ${comments.length} comments, ${timeline.length} timeline events, ${candidate.linked_prs.length} linked PRs`);
|
||||
}
|
||||
|
||||
await fs.writeFile("candidates.json", JSON.stringify(candidates, null, 2));
|
||||
console.log(`Wrote ${candidates.length} candidates to candidates.json`);
|
||||
65
.github/workflows/issue-resolution-triage.yml
vendored
65
.github/workflows/issue-resolution-triage.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: issue-resolution-triage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [github-issue-resolver]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "If true, do not close issues"
|
||||
required: false
|
||||
default: "true"
|
||||
max_issues:
|
||||
description: "How many issues to process"
|
||||
required: false
|
||||
default: "100"
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
models: read
|
||||
|
||||
# todo: remove hardcoded values
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||
DRY_RUN: "true"
|
||||
MAX_ISSUES: "100"
|
||||
REPO: ${{ github.repository }}
|
||||
PROJECT_ID: "PVT_kwDOBfz4Jc4BVeWR"
|
||||
PROJECT_STATUS_FIELD_ID: "PVTSSF_lADOBfz4Jc4BVeWRzhQ56sU"
|
||||
PROJECT_CONFIDENCE_FIELD_ID: "PVTF_lADOBfz4Jc4BVeWRzhQ57x4"
|
||||
PROJECT_REASON_FIELD_ID: "PVTF_lADOBfz4Jc4BVeWRzhQ5-Lg"
|
||||
PROJECT_EVIDENCE_FIELD_ID: "PVTF_lADOBfz4Jc4BVeWRzhQ5-Pw"
|
||||
PROJECT_LINKED_PR_FIELD_ID: "PVTF_lADOBfz4Jc4BVeWRzhQ56sc"
|
||||
PROJECT_REPO_FIELD_ID: "PVTF_lADOBfz4Jc4BVeWRzhQ56sk"
|
||||
PROJECT_STATUS_OPTION_NEEDS_REVIEW_ID: "a55a2be9"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .github/issue-resolution
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- run: node scripts/fetch-candidates.mjs
|
||||
- run: node scripts/classify-candidates.mjs
|
||||
- run: node scripts/apply-decisions.mjs
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: triage-results
|
||||
path: |
|
||||
.github/issue-resolution/candidates.json
|
||||
.github/issue-resolution/decisions.json
|
||||
@@ -200,6 +200,7 @@ Pop $0
|
||||
!macroend
|
||||
|
||||
Function .onInit
|
||||
SetRegView 64
|
||||
StrCpy $INSTDIR "${INSTALL_DIR}"
|
||||
ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString"
|
||||
${If} $R0 != ""
|
||||
@@ -214,6 +215,10 @@ ${If} $R0 != ""
|
||||
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.onInit
|
||||
SetRegView 64
|
||||
FunctionEnd
|
||||
######################################################################
|
||||
Section -MainProgram
|
||||
${INSTALL_TYPE}
|
||||
@@ -228,6 +233,7 @@ Section -MainProgram
|
||||
!else
|
||||
File /r "..\\dist\\netbird_windows_amd64\\"
|
||||
!endif
|
||||
File "..\\client\\ui\\assets\\netbird.png"
|
||||
SectionEnd
|
||||
######################################################################
|
||||
|
||||
@@ -247,9 +253,11 @@ WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||
; Create autostart registry entry based on checkbox
|
||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||
${If} $AutostartEnabled == "1"
|
||||
WriteRegStr HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" "$INSTDIR\${UI_APP_EXE}.exe"
|
||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||
${Else}
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DetailPrint "Autostart not enabled by user"
|
||||
${EndIf}
|
||||
@@ -283,6 +291,8 @@ ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||
|
||||
; Remove autostart registry entry
|
||||
DetailPrint "Removing autostart registry entry if exists..."
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
|
||||
; Handle data deletion based on checkbox
|
||||
@@ -321,6 +331,7 @@ DetailPrint "Removing registry keys..."
|
||||
DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}"
|
||||
DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}"
|
||||
DeleteRegKey ${REG_ROOT} "${UI_REG_APP_PATH}"
|
||||
DeleteRegKey HKCU "Software\Classes\AppUserModelId\${APP_NAME}"
|
||||
|
||||
DetailPrint "Removing application directory from PATH..."
|
||||
EnVar::SetHKLM
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
<Component Id="NetbirdFiles" Guid="db3165de-cc6e-4922-8396-9d892950e23e" Bitness="always64">
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird.exe" KeyPath="yes" />
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\netbird-ui.exe">
|
||||
<Shortcut Id="NetbirdDesktopShortcut" Directory="DesktopFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon" />
|
||||
<Shortcut Id="NetbirdStartMenuShortcut" Directory="StartMenuFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon" />
|
||||
<Shortcut Id="NetbirdDesktopShortcut" Directory="DesktopFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon">
|
||||
<ShortcutProperty Key="System.AppUserModel.ID" Value="NetBird" />
|
||||
<ShortcutProperty Key="System.AppUserModel.ToastActivatorCLSID" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||
</Shortcut>
|
||||
<Shortcut Id="NetbirdStartMenuShortcut" Directory="StartMenuFolder" Name="NetBird" WorkingDirectory="NetbirdInstallDir" Icon="NetbirdIcon">
|
||||
<ShortcutProperty Key="System.AppUserModel.ID" Value="NetBird" />
|
||||
<ShortcutProperty Key="System.AppUserModel.ToastActivatorCLSID" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||
</Shortcut>
|
||||
</File>
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
||||
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
||||
<?if $(var.ArchSuffix) = "amd64" ?>
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
|
||||
<?endif ?>
|
||||
@@ -46,8 +53,19 @@
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<!-- Per-user component: HKCU keypath (auto GUID via "*"), separate from
|
||||
the per-machine NetbirdFiles component to satisfy ICE57. -->
|
||||
<StandardDirectory Id="ProgramMenuFolder">
|
||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="NetbirdFilesComponent">
|
||||
<ComponentRef Id="NetbirdFiles" />
|
||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||
</ComponentGroup>
|
||||
|
||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||
|
||||
@@ -42,6 +42,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
"github.com/netbirdio/netbird/client/ui/event"
|
||||
"github.com/netbirdio/netbird/client/ui/notifier"
|
||||
"github.com/netbirdio/netbird/client/ui/process"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
|
||||
@@ -260,6 +261,7 @@ type serviceClient struct {
|
||||
|
||||
// application with main windows.
|
||||
app fyne.App
|
||||
notifier notifier.Notifier
|
||||
wSettings fyne.Window
|
||||
showAdvancedSettings bool
|
||||
sendNotification bool
|
||||
@@ -364,6 +366,7 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
|
||||
cancel: cancel,
|
||||
addr: args.addr,
|
||||
app: args.app,
|
||||
notifier: notifier.New(args.app),
|
||||
logFile: args.logFile,
|
||||
sendNotification: false,
|
||||
|
||||
@@ -892,7 +895,7 @@ func (s *serviceClient) updateStatus() error {
|
||||
if err != nil {
|
||||
log.Errorf("get service status: %v", err)
|
||||
if s.connected {
|
||||
s.app.SendNotification(fyne.NewNotification("Error", "Connection to service lost"))
|
||||
s.notifier.Send("Error", "Connection to service lost")
|
||||
}
|
||||
s.setDisconnectedStatus()
|
||||
return err
|
||||
@@ -1109,7 +1112,7 @@ func (s *serviceClient) onTrayReady() {
|
||||
}
|
||||
}()
|
||||
|
||||
s.eventManager = event.NewManager(s.app, s.addr)
|
||||
s.eventManager = event.NewManager(s.notifier, s.addr)
|
||||
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
|
||||
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
||||
if event.Category == proto.SystemEvent_SYSTEM {
|
||||
@@ -1548,7 +1551,7 @@ func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) {
|
||||
|
||||
if enforced && s.lastNotifiedVersion != newVersion {
|
||||
s.lastNotifiedVersion = newVersion
|
||||
s.app.SendNotification(fyne.NewNotification("Update available", "A new version "+newVersion+" is ready to install"))
|
||||
s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
@@ -18,11 +17,17 @@ import (
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
)
|
||||
|
||||
// Notifier sends desktop notifications. Defined here so the event package
|
||||
// does not depend on fyne or the platform-specific notifier implementation.
|
||||
type Notifier interface {
|
||||
Send(title, body string)
|
||||
}
|
||||
|
||||
type Handler func(*proto.SystemEvent)
|
||||
|
||||
type Manager struct {
|
||||
app fyne.App
|
||||
addr string
|
||||
notifier Notifier
|
||||
addr string
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
@@ -31,10 +36,10 @@ type Manager struct {
|
||||
handlers []Handler
|
||||
}
|
||||
|
||||
func NewManager(app fyne.App, addr string) *Manager {
|
||||
func NewManager(notifier Notifier, addr string) *Manager {
|
||||
return &Manager{
|
||||
app: app,
|
||||
addr: addr,
|
||||
notifier: notifier,
|
||||
addr: addr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +119,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) {
|
||||
if id != "" {
|
||||
body += fmt.Sprintf(" ID: %s", id)
|
||||
}
|
||||
e.app.SendNotification(fyne.NewNotification(title, body))
|
||||
e.notifier.Send(title, body)
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/systray"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -87,7 +86,7 @@ func (h *eventHandler) handleConnectClick() {
|
||||
if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) {
|
||||
log.Debugf("connect operation cancelled by user")
|
||||
} else {
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to connect"))
|
||||
h.client.notifier.Send("Error", "Failed to connect")
|
||||
log.Errorf("connect failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +111,7 @@ func (h *eventHandler) handleDisconnectClick() {
|
||||
if err := h.client.menuDownClick(); err != nil {
|
||||
st, ok := status.FromError(err)
|
||||
if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) {
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to disconnect"))
|
||||
h.client.notifier.Send("Error", "Failed to disconnect")
|
||||
log.Errorf("disconnect failed: %v", err)
|
||||
} else {
|
||||
log.Debugf("disconnect cancelled or already disconnecting")
|
||||
@@ -130,7 +129,7 @@ func (h *eventHandler) handleAllowSSHClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update SSH settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update SSH settings")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -140,7 +139,7 @@ func (h *eventHandler) handleAutoConnectClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update auto-connect settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update auto-connect settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +148,7 @@ func (h *eventHandler) handleRosenpassClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update Rosenpass settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update Rosenpass settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +157,7 @@ func (h *eventHandler) handleLazyConnectionClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update lazy connection settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update lazy connection settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +166,7 @@ func (h *eventHandler) handleBlockInboundClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update block inbound settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update block inbound settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +175,7 @@ func (h *eventHandler) handleNotificationsClick() {
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update notifications settings"))
|
||||
h.client.notifier.Send("Error", "Failed to update notifications settings")
|
||||
} else if h.client.eventManager != nil {
|
||||
h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked())
|
||||
}
|
||||
|
||||
27
client/ui/notifier/notifier.go
Normal file
27
client/ui/notifier/notifier.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Package notifier sends desktop notifications. On Windows it uses the WinRT
|
||||
// COM API directly via go-toast/v2 to avoid the PowerShell window flash that
|
||||
// fyne's default implementation produces. On other platforms it delegates to
|
||||
// fyne.
|
||||
package notifier
|
||||
|
||||
import "fyne.io/fyne/v2"
|
||||
|
||||
// Notifier sends desktop notifications.
|
||||
type Notifier interface {
|
||||
Send(title, body string)
|
||||
}
|
||||
|
||||
// New returns a platform-specific Notifier. The fyne app is used as the
|
||||
// fallback notifier on platforms where no native implementation is wired up,
|
||||
// and on Windows when the COM path fails to initialize.
|
||||
func New(app fyne.App) Notifier {
|
||||
return newNotifier(app)
|
||||
}
|
||||
|
||||
type fyneNotifier struct {
|
||||
app fyne.App
|
||||
}
|
||||
|
||||
func (f *fyneNotifier) Send(title, body string) {
|
||||
f.app.SendNotification(fyne.NewNotification(title, body))
|
||||
}
|
||||
9
client/ui/notifier/notifier_other.go
Normal file
9
client/ui/notifier/notifier_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package notifier
|
||||
|
||||
import "fyne.io/fyne/v2"
|
||||
|
||||
func newNotifier(app fyne.App) Notifier {
|
||||
return &fyneNotifier{app: app}
|
||||
}
|
||||
88
client/ui/notifier/notifier_windows.go
Normal file
88
client/ui/notifier/notifier_windows.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
toast "git.sr.ht/~jackmordaunt/go-toast/v2"
|
||||
"git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// appID is the AppUserModelID shown in the Windows Action Center. It
|
||||
// must match the System.AppUserModel.ID property set on the Start Menu
|
||||
// shortcut by the MSI (see client/netbird.wxs); otherwise Windows
|
||||
// groups toasts under a separate, unbranded entry.
|
||||
appID = "NetBird"
|
||||
|
||||
// appGUID identifies the COM activation callback class. Generated once
|
||||
// for NetBird; do not change without coordinating an installer bump,
|
||||
// since old registry entries pointing at the previous GUID would orphan.
|
||||
appGUID = "{0E1B4DE7-E148-432B-9814-544F941826EC}"
|
||||
)
|
||||
|
||||
type comNotifier struct {
|
||||
fallback *fyneNotifier
|
||||
ready bool
|
||||
iconPath string
|
||||
}
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
func newNotifier(app fyne.App) Notifier {
|
||||
n := &comNotifier{
|
||||
fallback: &fyneNotifier{app: app},
|
||||
iconPath: resolveIcon(),
|
||||
}
|
||||
initOnce.Do(func() {
|
||||
initErr = wintoast.SetAppData(wintoast.AppData{
|
||||
AppID: appID,
|
||||
GUID: appGUID,
|
||||
IconPath: n.iconPath,
|
||||
})
|
||||
})
|
||||
if initErr != nil {
|
||||
log.Warnf("toast: register app data failed, falling back to fyne notifications: %v", initErr)
|
||||
return n.fallback
|
||||
}
|
||||
n.ready = true
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *comNotifier) Send(title, body string) {
|
||||
if !n.ready {
|
||||
n.fallback.Send(title, body)
|
||||
return
|
||||
}
|
||||
notification := toast.Notification{
|
||||
AppID: appID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Icon: n.iconPath,
|
||||
}
|
||||
if err := notification.Push(); err != nil {
|
||||
log.Warnf("toast: push failed, using fyne fallback: %v", err)
|
||||
n.fallback.Send(title, body)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveIcon returns an absolute path to the toast icon, or an empty string
|
||||
// when no icon can be located. Windows requires a PNG/JPG for the
|
||||
// AppUserModelId IconUri registry value; .ico is silently ignored.
|
||||
func resolveIcon() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
candidate := filepath.Join(filepath.Dir(exe), "netbird.png")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -548,7 +548,7 @@ func (p *profileMenu) refresh() {
|
||||
if err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
// show notification dialog
|
||||
p.app.SendNotification(fyne.NewNotification("Error", "Failed to switch profile"))
|
||||
p.serviceClient.notifier.Send("Error", "Failed to switch profile")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -628,9 +628,9 @@ func (p *profileMenu) refresh() {
|
||||
}
|
||||
if err := p.eventHandler.logout(p.ctx); err != nil {
|
||||
log.Errorf("logout failed: %v", err)
|
||||
p.app.SendNotification(fyne.NewNotification("Error", "Failed to deregister"))
|
||||
p.serviceClient.notifier.Send("Error", "Failed to deregister")
|
||||
} else {
|
||||
p.app.SendNotification(fyne.NewNotification("Success", "Deregistered successfully"))
|
||||
p.serviceClient.notifier.Send("Success", "Deregistered successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -30,6 +30,7 @@ require (
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.0
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6
|
||||
|
||||
2
go.sum
2
go.sum
@@ -15,6 +15,8 @@ fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
|
||||
fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4=
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=
|
||||
|
||||
@@ -231,7 +231,20 @@ get_upstream_host() {
|
||||
|
||||
wait_management_proxy() {
|
||||
local proxy_container="${1:-traefik}"
|
||||
local use_docker_logs=false
|
||||
set +e
|
||||
|
||||
if [[ "$proxy_container" == "detect-traefik" ]]; then
|
||||
proxy_container=$(docker ps --format "{{.ID}}\t{{.Image}}\t{{.Ports}}" \
|
||||
| awk -F'\t' '$2 ~ /traefik/ && $3 ~ /:(80|443)->/ {print $1; exit}')
|
||||
|
||||
if [[ -z "$proxy_container" ]]; then
|
||||
echo "Warning: could not auto-detect Traefik container, log output will be skipped on timeout." > /dev/stderr
|
||||
else
|
||||
use_docker_logs=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -n "Waiting for NetBird server to become ready"
|
||||
counter=1
|
||||
while true; do
|
||||
@@ -242,7 +255,13 @@ wait_management_proxy() {
|
||||
if [[ $counter -eq 60 ]]; then
|
||||
echo ""
|
||||
echo "Taking too long. Checking logs..."
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container"
|
||||
if [[ -n "$proxy_container" ]]; then
|
||||
if [[ "$use_docker_logs" == "true" ]]; then
|
||||
docker logs --tail=20 "$proxy_container"
|
||||
else
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container"
|
||||
fi
|
||||
fi
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server
|
||||
fi
|
||||
echo -n " ."
|
||||
@@ -518,7 +537,7 @@ start_services_and_show_instructions() {
|
||||
$DOCKER_COMPOSE_COMMAND up -d
|
||||
|
||||
sleep 3
|
||||
wait_management_direct
|
||||
wait_management_proxy detect-traefik
|
||||
|
||||
echo -e "$MSG_DONE"
|
||||
print_post_setup_instructions
|
||||
|
||||
@@ -33,8 +33,8 @@ import (
|
||||
|
||||
const remoteJobsMinVer = "0.64.0"
|
||||
|
||||
// GetPeers returns a list of peers under the given account filtering out peers that do not belong to a user if
|
||||
// the current user is not an admin.
|
||||
// GetPeers returns peers visible to the user within an account.
|
||||
// Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings.
|
||||
func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) {
|
||||
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
@@ -46,14 +46,8 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
|
||||
accountPeers, err := am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// @note if the user has permission to read peers it shows all account peers
|
||||
if allowed {
|
||||
return accountPeers, nil
|
||||
return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter)
|
||||
}
|
||||
|
||||
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -65,41 +59,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
|
||||
return []*nbpeer.Peer{}, nil
|
||||
}
|
||||
|
||||
// @note if it does not have permission read peers then only display it's own peers
|
||||
peers := make([]*nbpeer.Peer, 0)
|
||||
peersMap := make(map[string]*nbpeer.Peer)
|
||||
|
||||
for _, peer := range accountPeers {
|
||||
if user.Id != peer.UserID {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, peer)
|
||||
peersMap[peer.ID] = peer
|
||||
}
|
||||
|
||||
return am.getUserAccessiblePeers(ctx, accountID, peersMap, peers)
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, accountID string, peersMap map[string]*nbpeer.Peer, peers []*nbpeer.Peer) ([]*nbpeer.Peer, error) {
|
||||
account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fetch all the peers that have access to the user's peers
|
||||
for _, peer := range peers {
|
||||
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, peer, approvedPeersMap, account.GetActiveGroupUsers())
|
||||
for _, p := range aclPeers {
|
||||
peersMap[p.ID] = p
|
||||
}
|
||||
}
|
||||
|
||||
return maps.Values(peersMap), nil
|
||||
return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
||||
}
|
||||
|
||||
// MarkPeerConnected marks peer as connected (true) or disconnected (false)
|
||||
@@ -1230,7 +1190,8 @@ func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Se
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPeer for a given accountID, peerID and userID error if not found.
|
||||
// GetPeer returns a peer visible to the user within an account.
|
||||
// Users with "peers:read" permission can access any peer. Otherwise, users can access only their own peer.
|
||||
func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) {
|
||||
peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
|
||||
if err != nil {
|
||||
@@ -1255,36 +1216,6 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID,
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
return am.checkIfUserOwnsPeer(ctx, accountID, userID, peer)
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) checkIfUserOwnsPeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) {
|
||||
account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// it is also possible that user doesn't own the peer but some of his peers have access to it,
|
||||
// this is a valid case, show the peer as well.
|
||||
userPeers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range userPeers {
|
||||
aclPeers, _, _, _ := account.GetPeerConnectionResources(ctx, p, approvedPeersMap, account.GetActiveGroupUsers())
|
||||
for _, aclPeer := range aclPeers {
|
||||
if aclPeer.ID == peer.ID {
|
||||
return peer, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, status.Errorf(status.Internal, "user %s has no access to peer %s under account %s", userID, peer.ID, accountID)
|
||||
}
|
||||
|
||||
|
||||
@@ -559,25 +559,9 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
|
||||
}
|
||||
assert.NotNil(t, peer)
|
||||
|
||||
// the user can see peer2 because peer1 of the user has access to peer2 due to the All group and the default rule 0 all-to-all access
|
||||
peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
assert.NotNil(t, peer)
|
||||
|
||||
// delete the all-to-all policy so that user's peer1 has no access to peer2
|
||||
for _, policy := range account.Policies {
|
||||
err = manager.DeletePolicy(context.Background(), accountID, policy.ID, adminUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// at this point the user can't see the details of peer2
|
||||
peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) //nolint
|
||||
// the user can NOT see peer2 because it is not owned by them.
|
||||
// Regular users only see peers they directly own.
|
||||
_, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser)
|
||||
assert.Error(t, err)
|
||||
|
||||
// admin users can always access all the peers
|
||||
|
||||
Reference in New Issue
Block a user