From d5c58c66176481c9731dd36db85a800176ca1e7f Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 7 Jan 2026 14:53:39 -0500 Subject: [PATCH 1/2] Adding in changes --- .env.example | 1 + README.md | 1 + docs/setup-env.md | 8 + src/bot.ts | 67 +++-- src/config/env.ts | 59 +++- src/services/github/issueAssigneePoller.ts | 330 +++++++++++++++++++++ src/services/github/prPoller.ts | 38 ++- 7 files changed, 466 insertions(+), 38 deletions(-) create mode 100644 src/services/github/issueAssigneePoller.ts diff --git a/.env.example b/.env.example index 9b1c0487..b35a1295 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,7 @@ GITHUB_REPO= # This must be a numeric channel ID, not a channel name. # Enable Developer Mode in Discord → right-click channel → Copy ID GITHUB_ANNOUNCE_CHANNEL_ID= +GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID= # ============================================================ # GitHub PR Polling Interval diff --git a/README.md b/README.md index d4872ded..70f1c4f1 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ OmegaBot is online │ │ │ ├── githubApi.ts │ │ │ ├── githubClient.ts │ │ │ ├── githubErrorMessage.ts +│ │ │ ├── issueAssigneePoller.ts │ │ │ ├── lastSeenStore.ts │ │ │ ├── prFormatter.ts │ │ │ ├── prPoller.ts diff --git a/docs/setup-env.md b/docs/setup-env.md index 50c53dd5..f7e80367 100644 --- a/docs/setup-env.md +++ b/docs/setup-env.md @@ -33,7 +33,15 @@ OPENAI_API_KEY=your-key GITHUB_TOKEN=your-github-pat GITHUB_OWNER=org-or-user GITHUB_REPO=repo-name + +# Channel for PR creation / PR updates GITHUB_ANNOUNCE_CHANNEL_ID=channel-id + +# Channel for issue + PR assignee changes and closures +# (can be the same as above if you want) +GITHUB_ASSIGNEE_CHANNEL_ID=channel-id + +# Polling interval (milliseconds) GITHUB_POLL_INTERVAL_MS=60000 ``` diff --git a/src/bot.ts b/src/bot.ts index 916e15f5..c941cf2d 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,6 +4,7 @@ import { Client, GatewayIntentBits } from "discord.js"; import { loadCommands, type CommandClient } from "./services/discord/commandLoader.js"; import { handleInteraction } from "./services/discord/interactionHandler.js"; import { pollPullRequestsOnce } from "./services/github/prPoller.js"; +import { pollIssueAssigneesOnce } from "./services/github/issueAssigneePoller.js"; import { onGuildMemberAdd } from "./services/welcome/welcomeHandler.js"; import { env } from "./config/env.js"; import { logger } from "./utils/logger.js"; @@ -57,14 +58,11 @@ client.on("guildMemberAdd", async (member) => { }); /** - * Optional GitHub PR polling. - * Enabled only when all required env vars are present. + * Optional GitHub polling. + * Each stream is enabled only when all required env vars are present. */ -const githubPollingEnabled = - !!env.githubToken && - !!env.githubOwner && - !!env.githubRepo && - !!env.githubAnnounceChannelId; +const githubPrPollingEnabled = env.githubPrPollingEnabled; +const githubAssigneePollingEnabled = env.githubAssigneePollingEnabled; /** * Log once when the bot is ready. @@ -72,19 +70,37 @@ const githubPollingEnabled = client.once("clientReady", () => { logger.info("OmegaBot is online"); - if (githubPollingEnabled) { + if (githubPrPollingEnabled) { logger.info( { owner: env.githubOwner, repo: env.githubRepo, - channelId: env.githubAnnounceChannelId, + channelId: env.githubPrAnnounceChannelId, intervalMs: env.githubPollIntervalMs, }, - "GitHub PR polling enabled", + "GitHub PR polling enabled (new PRs)", ); } else { logger.info("GitHub PR polling disabled"); } + + if (githubAssigneePollingEnabled) { + logger.info( + { + owner: env.githubOwner, + repo: env.githubRepo, + channelId: env.githubAssigneeAnnounceChannelId, + intervalMs: env.githubPollIntervalMs, + }, + "GitHub assignee polling enabled (assignee changes)", + ); + } else { + logger.info("GitHub assignee polling disabled"); + } + + if (!githubPrPollingEnabled && !githubAssigneePollingEnabled) { + logger.info("GitHub polling disabled"); + } }); /** @@ -93,15 +109,28 @@ client.once("clientReady", () => { void client.login(env.token); /** - * Schedule GitHub PR polling (if enabled). + * Schedule GitHub polling (if enabled). */ -if (githubPollingEnabled) { +if (githubPrPollingEnabled || githubAssigneePollingEnabled) { setInterval(() => { - void pollPullRequestsOnce({ - client, - owner: env.githubOwner!, - repo: env.githubRepo!, - announceChannelId: env.githubAnnounceChannelId!, - }); + // 1) PR creation polling (new PR detection) + if (githubPrPollingEnabled) { + void pollPullRequestsOnce({ + client, + owner: env.githubOwner!, + repo: env.githubRepo!, + announceChannelId: env.githubPrAnnounceChannelId!, + }); + } + + // 2) Assignee change polling (issues and PRs) + if (githubAssigneePollingEnabled) { + void pollIssueAssigneesOnce({ + client, + owner: env.githubOwner!, + repo: env.githubRepo!, + announceChannelId: env.githubAssigneeAnnounceChannelId!, + }); + } }, env.githubPollIntervalMs); -} +} \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index 55d06b86..7dd0367d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,3 +1,5 @@ +// src/config/env.ts + import dotenv from "dotenv"; dotenv.config({ path: ".env" }); @@ -74,16 +76,24 @@ type SummaryMode = "local" | "llm"; * Required only when GitHub-backed features are enabled. * * - GITHUB_OWNER - * Default repository owner for polling PRs (optional). + * Default repository owner for polling (optional). * * - GITHUB_REPO - * Default repository name for polling PRs (optional). + * Default repository name for polling (optional). * * - GITHUB_ANNOUNCE_CHANNEL_ID - * Discord channel ID where PR announcements are posted (optional). + * Legacy: single channel where GitHub announcements are posted (optional). + * + * - GITHUB_PR_ANNOUNCE_CHANNEL_ID + * NEW: channel where PR creation announcements are posted (optional). + * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. + * + * - GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID + * NEW: channel where assignee change announcements are posted (optional). + * Falls back to GITHUB_ANNOUNCE_CHANNEL_ID if unset. * * - GITHUB_POLL_INTERVAL_MS - * Polling interval for PR announcements. + * Polling interval for GitHub polling. * Defaults to 60 seconds if not provided. * * All GitHub-related fields are OPTIONAL so the bot can run @@ -96,6 +106,15 @@ if (summaryMode === "llm" && !process.env.OPENAI_API_KEY) { throw new Error("OPENAI_API_KEY is required when SUMMARY_MODE=llm"); } +const legacyGithubAnnounceChannelId = process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null; + +// New split channels (fall back to legacy) +const githubPrAnnounceChannelId = + process.env.GITHUB_PR_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; + +const githubAssigneeAnnounceChannelId = + process.env.GITHUB_ASSIGNEE_ANNOUNCE_CHANNEL_ID ?? legacyGithubAnnounceChannelId; + export const env = { /* ---------------------------------------------------------------- */ /* Discord (required) */ @@ -128,18 +147,22 @@ export const env = { // Auth token for GitHub REST API (optional) githubToken: process.env.GITHUB_TOKEN ?? null, - // Default repo configuration for PR polling (optional) + // Default repo configuration for polling (optional) githubOwner: process.env.GITHUB_OWNER ?? null, githubRepo: process.env.GITHUB_REPO ?? null, - // Where PR announcements should be posted (optional) - githubAnnounceChannelId: process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null, + // Legacy: Where GitHub announcements should be posted (optional) + githubAnnounceChannelId: legacyGithubAnnounceChannelId, + + // NEW: split announcement channels (optional; fall back to legacy) + githubPrAnnounceChannelId, + githubAssigneeAnnounceChannelId, // Polling interval (ms). Defaults to 60s. githubPollIntervalMs: envInt("GITHUB_POLL_INTERVAL_MS", 60_000), /** - * Feature gate: + * Legacy feature gate: * * GitHub announcement polling is enabled ONLY when all required * configuration is present. This prevents the bot from attempting @@ -151,6 +174,24 @@ export const env = { Boolean(process.env.GITHUB_REPO) && Boolean(process.env.GITHUB_ANNOUNCE_CHANNEL_ID), + /** + * NEW feature gates: + * + * Separate enablement for each GitHub polling stream. + * These fall back to the legacy channel var automatically. + */ + githubPrPollingEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(githubPrAnnounceChannelId), + + githubAssigneePollingEnabled: + Boolean(process.env.GITHUB_TOKEN) && + Boolean(process.env.GITHUB_OWNER) && + Boolean(process.env.GITHUB_REPO) && + Boolean(githubAssigneeAnnounceChannelId), + /** * Helper for GitHub-only code paths. * @@ -168,4 +209,4 @@ export const env = { } return token; }, -}; +}; \ No newline at end of file diff --git a/src/services/github/issueAssigneePoller.ts b/src/services/github/issueAssigneePoller.ts new file mode 100644 index 00000000..94b4ccd0 --- /dev/null +++ b/src/services/github/issueAssigneePoller.ts @@ -0,0 +1,330 @@ +// src/services/github/issueAssigneePoller.ts + +import type { Client } from "discord.js"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { env } from "../../config/env.js"; +import { logger } from "../../utils/logger.js"; + +type PollArgs = { + client: Client; + owner: string; + repo: string; + announceChannelId: string; +}; + +type GitHubAssignee = { login: string }; + +type GitHubIssueItem = { + number: number; + title: string; + html_url: string; + assignees?: GitHubAssignee[]; + // Present on PRs returned in the issues list + pull_request?: unknown; +}; + +type TrackedItem = { + kind: "PR" | "Issue"; + title: string; + url: string; + assignees: string[]; +}; + +type StateFile = { + /** + * Back-compat: + * Older state files might only have assigneesByNumber. + * We’ll load them and upgrade in-memory automatically. + */ + assigneesByNumber?: Record; + itemsByNumber?: Record; + initializedAt: string; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); + +function uniqSorted(list: string[]): string[] { + return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort( + (a, b) => a.localeCompare(b), + ); +} + +function diff(prev: string[], next: string[]) { + const prevSet = new Set(prev); + const nextSet = new Set(next); + + const added = next.filter((x) => !prevSet.has(x)); + const removed = prev.filter((x) => !nextSet.has(x)); + + return { added, removed, changed: added.length > 0 || removed.length > 0 }; +} + +async function ensureDataDir(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StateFile; + if (!parsed || typeof parsed !== "object") return null; + + // We accept either the old shape (assigneesByNumber) or new shape (itemsByNumber) + const hasOld = + !!parsed.assigneesByNumber && typeof parsed.assigneesByNumber === "object"; + const hasNew = !!parsed.itemsByNumber && typeof parsed.itemsByNumber === "object"; + + if (!hasOld && !hasNew) return null; + if (!parsed.initializedAt || typeof parsed.initializedAt !== "string") return null; + + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StateFile): Promise { + await ensureDataDir(); + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); +} + +async function getAnnounceChannel( + client: Client, + channelId: string, +): Promise<{ send: (content: string) => Promise }> { + const ch = await client.channels.fetch(channelId); + + if (!ch || !ch.isTextBased()) { + throw new Error(`Announce channel is not a text channel: ${channelId}`); + } + + // TypeScript guard: not all TextBasedChannel unions guarantee send() + if (!("send" in ch) || typeof ch.send !== "function") { + throw new Error(`Announce channel does not support send(): ${channelId}`); + } + + return ch; +} + +async function githubFetchJson(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "OmegaBot", + Authorization: `token ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API error: ${res.status} ${res.statusText} (${body.slice(0, 200)})`, + ); + } + + return (await res.json()) as T; +} + +function stateToItems(existing: StateFile): Record { + // New format available + if (existing.itemsByNumber && typeof existing.itemsByNumber === "object") { + return existing.itemsByNumber; + } + + // Upgrade old format in-memory (no title/url/kind available) + const old = existing.assigneesByNumber ?? {}; + const upgraded: Record = {}; + for (const [num, assignees] of Object.entries(old)) { + upgraded[num] = { + kind: "Issue", + title: "(unknown title)", + url: "(unknown url)", + assignees: uniqSorted(assignees ?? []), + }; + } + return upgraded; +} + +/** + * Poll open issues (includes PRs) and notify on: + * - assignee changes (add/remove/replace/multi/unassign) + * - issues/PRs that are no longer open (closed/merged/etc) + * + * Notes: + * - Baseline-first: first successful run saves state and does NOT notify. + * - State is persisted to ./data/github-assignees.json + */ +export async function pollIssueAssigneesOnce(args: PollArgs): Promise { + const { client, owner, repo, announceChannelId } = args; + + let token: string; + try { + token = env.requireGithubToken(); + } catch (err) { + logger.warn({ err }, "[github/assignees] missing GITHUB_TOKEN, skipping"); + return; + } + + let channel: { send: (content: string) => Promise }; + try { + channel = await getAnnounceChannel(client, announceChannelId); + } catch (err) { + logger.error({ err, announceChannelId }, "[github/assignees] bad announce channel"); + return; + } + + // Load state (or init) + const existing = (await loadState()) ?? { + assigneesByNumber: {}, + initializedAt: new Date().toISOString(), + }; + + const existingItemsByNumber = stateToItems(existing); + const hadExistingState = Object.keys(existingItemsByNumber).length > 0; + + // Fetch open issues (includes PRs) + const url = `https://api.github.com/repos/${encodeURIComponent( + owner, + )}/${encodeURIComponent(repo)}/issues?state=open&per_page=100`; + + let items: GitHubIssueItem[]; + try { + items = await githubFetchJson(url, token); + } catch (err) { + logger.error({ err, owner, repo }, "[github/assignees] fetch failed"); + return; + } + + const nextItemsByNumber: Record = {}; + const notifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + added: string[]; + removed: string[]; + }> = []; + + for (const item of items) { + const numberKey = String(item.number); + + const kind: "PR" | "Issue" = item.pull_request ? "PR" : "Issue"; + const nextAssignees = uniqSorted((item.assignees ?? []).map((a) => a.login)); + + nextItemsByNumber[numberKey] = { + kind, + title: item.title, + url: item.html_url, + assignees: nextAssignees, + }; + + const prevAssignees = uniqSorted(existingItemsByNumber[numberKey]?.assignees ?? []); + const { added, removed, changed } = diff(prevAssignees, nextAssignees); + + if (hadExistingState && changed) { + notifications.push({ + kind, + number: item.number, + title: item.title, + url: item.html_url, + added, + removed, + }); + } + } + + // Detect items that were previously open but are no longer open + const closedNotifications: Array<{ + kind: "PR" | "Issue"; + number: number; + title: string; + url: string; + }> = []; + + if (hadExistingState) { + const prevNumbers = new Set(Object.keys(existingItemsByNumber)); + const nextNumbers = new Set(Object.keys(nextItemsByNumber)); + + for (const num of prevNumbers) { + if (!nextNumbers.has(num)) { + const prev = existingItemsByNumber[num]; + closedNotifications.push({ + kind: prev?.kind ?? "Issue", + number: Number(num), + title: prev?.title ?? "(unknown title)", + url: prev?.url ?? "(unknown url)", + }); + } + } + } + + // Save next state (drops closed issues automatically) + const nextState: StateFile = { + itemsByNumber: nextItemsByNumber, + initializedAt: existing.initializedAt ?? new Date().toISOString(), + }; + + try { + await saveState(nextState); + } catch (err) { + logger.error({ err }, "[github/assignees] failed to save state"); + } + + // Baseline-first: no notifications on first successful run + if (!hadExistingState) { + logger.info( + { owner, repo, trackedCount: Object.keys(nextItemsByNumber).length }, + "[github/assignees] baseline saved (no notifications on first run)", + ); + return; + } + + // Announce assignee changes + for (const n of notifications) { + const parts: string[] = []; + parts.push(`**${n.kind} #${n.number}** assignees updated`); + parts.push(n.title); + parts.push(n.url); + + if (n.added.length) { + parts.push(`Added: ${n.added.map((u) => `\`${u}\``).join(", ")}`); + } + if (n.removed.length) { + parts.push(`Removed: ${n.removed.map((u) => `\`${u}\``).join(", ")}`); + } + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: n.number, kind: n.kind, added: n.added, removed: n.removed }, + "[github/assignees] announced", + ); + } catch (err) { + logger.error({ err, number: n.number }, "[github/assignees] failed to announce"); + } + } + + // Announce closures (issues + PRs) + for (const c of closedNotifications) { + const parts: string[] = []; + parts.push(`**${c.kind} #${c.number}** is no longer open`); + parts.push(c.title); + parts.push(c.url); + parts.push("(Closed/merged/etc — detected via polling)"); + + try { + await channel.send(parts.join("\n")); + logger.info( + { number: c.number, kind: c.kind }, + "[github/assignees] closure announced", + ); + } catch (err) { + logger.error( + { err, number: c.number }, + "[github/assignees] failed to announce closure", + ); + } + } +} \ No newline at end of file diff --git a/src/services/github/prPoller.ts b/src/services/github/prPoller.ts index 6223f49b..71383df1 100644 --- a/src/services/github/prPoller.ts +++ b/src/services/github/prPoller.ts @@ -8,19 +8,20 @@ import { logger } from "../../utils/logger.js"; /** * Minimal shape we need from the GitHub PR list for polling announcements. - * githubApi.listPullRequests must return objects with `updated_at`. + * githubApi.listPullRequests must return objects with `created_at`. */ type PrForPolling = { - updated_at: string; + created_at: string; }; /** - * Poll GitHub for new or updated PRs and announce them in a Discord channel. + * Poll GitHub for NEW PRs (created since last seen) and announce them in a Discord channel. * * Design goals: * - Never throw (background job safety) * - Log failures once with context * - Skip quietly when nothing to do + * - Baseline-first: first successful run stores lastSeen and does NOT announce */ export async function pollPullRequestsOnce(args: { client: Client; @@ -32,7 +33,8 @@ export async function pollPullRequestsOnce(args: { const { client, owner, repo, announceChannelId, limit = 20 } = args; try { - // Pull newest-updated first (based on githubApi query params) + // Pull newest-first (based on githubApi query params) + // IMPORTANT: We only announce PRs based on created_at (not updated_at) const prs = (await listPullRequests(owner, repo, { state: "open", limit, @@ -42,9 +44,25 @@ export async function pollPullRequestsOnce(args: { const lastSeen = getLastSeen(owner, repo) ?? 0; - // Keep only PRs updated after our stored timestamp + // Baseline-first: if we've never seen anything, set marker and do not announce + if (lastSeen === 0) { + const newestCreated = prs + .map((pr) => Date.parse(pr.created_at)) + .filter((ms) => !Number.isNaN(ms)) + .sort((a, b) => b - a)[0]; + + if (newestCreated && newestCreated > 0) { + // Store the timestamp as an ISO string via setLastSeenPr + setLastSeenPr(owner, repo, new Date(newestCreated).toISOString()); + logger.info({ owner, repo }, "PR poller baseline saved (no announcements)"); + } + + return; + } + + // Keep only PRs created after our stored timestamp const fresh = prs.filter((pr) => { - const ms = Date.parse(pr.updated_at); + const ms = Date.parse(pr.created_at); return !Number.isNaN(ms) && ms > lastSeen; }); @@ -61,17 +79,17 @@ export async function pollPullRequestsOnce(args: { // Post oldest-first so announcements read naturally const oldestFirst = [...fresh].sort( - (a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at), + (a, b) => Date.parse(a.created_at) - Date.parse(b.created_at), ); for (const pr of oldestFirst) { await fetched.send(formatPullRequest(pr)); } - // Update last-seen to newest PR we announced + // Update last-seen to newest PR we announced (by created_at) const newest = oldestFirst[oldestFirst.length - 1]; - setLastSeenPr(owner, repo, newest.updated_at); + setLastSeenPr(owner, repo, newest.created_at); } catch (err) { logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); } -} +} \ No newline at end of file From b65a7afe1e0ecdc53f09c00a466063e4f195aa6d Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 7 Jan 2026 14:53:44 -0500 Subject: [PATCH 2/2] style: auto-format with Prettier [skip-precheck] --- src/bot.ts | 2 +- src/config/env.ts | 2 +- src/services/github/issueAssigneePoller.ts | 6 +++--- src/services/github/prPoller.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index c941cf2d..924e477e 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -133,4 +133,4 @@ if (githubPrPollingEnabled || githubAssigneePollingEnabled) { }); } }, env.githubPollIntervalMs); -} \ No newline at end of file +} diff --git a/src/config/env.ts b/src/config/env.ts index 7dd0367d..21439692 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -209,4 +209,4 @@ export const env = { } return token; }, -}; \ No newline at end of file +}; diff --git a/src/services/github/issueAssigneePoller.ts b/src/services/github/issueAssigneePoller.ts index 94b4ccd0..7a928e1c 100644 --- a/src/services/github/issueAssigneePoller.ts +++ b/src/services/github/issueAssigneePoller.ts @@ -46,8 +46,8 @@ const DATA_DIR = path.join(process.cwd(), "data"); const STATE_PATH = path.join(DATA_DIR, "github-assignees.json"); function uniqSorted(list: string[]): string[] { - return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort( - (a, b) => a.localeCompare(b), + return Array.from(new Set(list.map((s) => s.trim()).filter(Boolean))).sort((a, b) => + a.localeCompare(b), ); } @@ -327,4 +327,4 @@ export async function pollIssueAssigneesOnce(args: PollArgs): Promise { ); } } -} \ No newline at end of file +} diff --git a/src/services/github/prPoller.ts b/src/services/github/prPoller.ts index 71383df1..9c662b13 100644 --- a/src/services/github/prPoller.ts +++ b/src/services/github/prPoller.ts @@ -92,4 +92,4 @@ export async function pollPullRequestsOnce(args: { } catch (err) { logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed"); } -} \ No newline at end of file +}