generated from nhcarrigan/template
feat: add scripts for managing discourse
This commit is contained in:
@@ -20,3 +20,8 @@ GITEA_TOKEN="op://Private/Gitea/token"
|
||||
|
||||
# DefectDojo
|
||||
DOJO_TOKEN="op://Private/DefectDojo/token"
|
||||
|
||||
# Discourse
|
||||
DISCOURSE_URL="op://Environment Variables - Development/Ephemere/Discourse URL"
|
||||
DISCOURSE_API_KEY="op://Environment Variables - Development/Ephemere/Discourse Key"
|
||||
DISCOURSE_API_USERNAME="op://Environment Variables - Development/Ephemere/Discourse Username"
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||
import type { CategoryGet, CategoryList } from "../interfaces/discourse.js";
|
||||
|
||||
const discourseUrl = process.env.DISCOURSE_URL;
|
||||
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||
|
||||
if (
|
||||
discourseUrl === undefined
|
||||
|| discourseApiKey === undefined
|
||||
|| discourseApiUsername === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const categoryResponse = await backoffAndRetry<CategoryList>(
|
||||
`${discourseUrl}/categories.json`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (categoryResponse === null) {
|
||||
throw new Error("Failed to get categories");
|
||||
}
|
||||
const categories = categoryResponse;
|
||||
|
||||
// Collect all categories including subcategories
|
||||
const allCategories: Array<{ id: number; name: string }> = [];
|
||||
const processedIds = new Set<number>();
|
||||
|
||||
// Process top-level categories and their subcategories
|
||||
for (const category of categories.category_list.categories) {
|
||||
// Add top-level category if not already processed
|
||||
if (!processedIds.has(category.id)) {
|
||||
allCategories.push({ id: category.id, name: category.name });
|
||||
processedIds.add(category.id);
|
||||
}
|
||||
|
||||
if (!Array.isArray(category.subcategory_ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add subcategories if they exist
|
||||
for (const subcategoryId of category.subcategory_ids) {
|
||||
if (!processedIds.has(subcategoryId)) {
|
||||
const subcategoryRequest = await backoffAndRetry<CategoryGet>(
|
||||
`${discourseUrl}/c/${subcategoryId.toString()}/show.json`,
|
||||
{
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (subcategoryRequest === null) {
|
||||
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||
continue;
|
||||
}
|
||||
if (subcategoryRequest.category?.name === undefined) {
|
||||
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||
console.log(Object.keys(subcategoryRequest));
|
||||
continue;
|
||||
}
|
||||
allCategories.push({
|
||||
id: subcategoryId,
|
||||
name: subcategoryRequest.category.name,
|
||||
});
|
||||
processedIds.add(subcategoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update all categories (including subcategories)
|
||||
for (const category of allCategories) {
|
||||
const { id, name } = category;
|
||||
console.log(`Updating category ${id.toString()}: ${name}`);
|
||||
const updateRequest = await backoffAndRetry<CategoryGet>(
|
||||
`${discourseUrl}/categories/${id.toString()}`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||
auto_close_based_on_last_post: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||
auto_close_hours: 672,
|
||||
name: name,
|
||||
}),
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
if (updateRequest === null) {
|
||||
console.error(`Failed to update category ${id.toString()}`);
|
||||
continue;
|
||||
}
|
||||
if (updateRequest.category?.auto_close_hours === undefined) {
|
||||
console.error(`Failed to update category ${id.toString()}`);
|
||||
console.error(JSON.stringify(updateRequest, null, 2));
|
||||
continue;
|
||||
}
|
||||
const { auto_close_hours: returnedAutoCloseHours } = updateRequest.category;
|
||||
console.log(
|
||||
`Updated category ${id.toString()}: ${name} to auto_close after ${returnedAutoCloseHours.toString()} hours`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||
import { sleep } from "../utils/sleep.js";
|
||||
import type { Topic, TopicList } from "../interfaces/discourse.js";
|
||||
|
||||
const discourseUrl = process.env.DISCOURSE_URL;
|
||||
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||
|
||||
if (
|
||||
discourseUrl === undefined
|
||||
|| discourseApiKey === undefined
|
||||
|| discourseApiUsername === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const apiHeaders = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Key": discourseApiKey,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Api-Username": discourseApiUsername,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a single page of topics from Discourse.
|
||||
* @param page - The page number to fetch.
|
||||
* @returns The topics from that page, or null if the request failed.
|
||||
*/
|
||||
const fetchTopicsPage = async(page: number): Promise<Array<Topic> | null> => {
|
||||
const url = `${discourseUrl}/latest.json?page=${page.toString()}`;
|
||||
console.log(`Fetching topics page ${page.toString()}...`);
|
||||
|
||||
const response = await backoffAndRetry<TopicList>(
|
||||
url,
|
||||
{
|
||||
headers: apiHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
if (response === null) {
|
||||
console.error(`Failed to fetch page ${page.toString()}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { topic_list: topicList } = response;
|
||||
const { topics } = topicList;
|
||||
console.log(
|
||||
`Page ${page.toString()}: Received ${topics.length.toString()} topics`,
|
||||
);
|
||||
|
||||
return topics;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the last activity date for a topic.
|
||||
* @param topic - The topic to get the activity date for.
|
||||
* @returns The date when the topic was last active.
|
||||
*/
|
||||
const getLastActivityDate = (topic: Topic): Date => {
|
||||
if (topic.last_posted_at !== null) {
|
||||
return new Date(topic.last_posted_at);
|
||||
}
|
||||
|
||||
if (topic.bumped_at !== null) {
|
||||
return new Date(topic.bumped_at);
|
||||
}
|
||||
|
||||
return new Date(topic.created_at);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches open topics from Discourse until we hit topics older than 6 months.
|
||||
* The /latest.json endpoint returns topics in a nested structure, so we need
|
||||
* to handle it differently than standard paginated endpoints.
|
||||
* @param sixMonthsAgo - The date cutoff for stopping pagination (6 months ago).
|
||||
* @returns Open topics that are newer than 6 months old.
|
||||
*/
|
||||
const fetchAllOpenTopics = async(sixMonthsAgo: Date): Promise<Array<Topic>> => {
|
||||
const allTopics: Array<Topic> = [];
|
||||
let page = 0;
|
||||
let topics: Array<Topic> | null = await fetchTopicsPage(page);
|
||||
|
||||
while (topics !== null && topics.length > 0) {
|
||||
let foundOldTopic = false;
|
||||
|
||||
for (const topic of topics) {
|
||||
// Skip closed topics
|
||||
if (topic.closed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastActivityDate = getLastActivityDate(topic);
|
||||
|
||||
// If we hit a topic older than 6 months, stop fetching
|
||||
if (lastActivityDate < sixMonthsAgo) {
|
||||
foundOldTopic = true;
|
||||
console.log(
|
||||
`Found topic older than 6 months (${lastActivityDate.toISOString()}), stopping pagination.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
allTopics.push(topic);
|
||||
}
|
||||
|
||||
if (foundOldTopic) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Continue to next page
|
||||
page = page + 1;
|
||||
topics = await fetchTopicsPage(page);
|
||||
}
|
||||
|
||||
console.log(`Total open topics fetched: ${allTopics.length.toString()}`);
|
||||
return allTopics;
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes a topic by ID.
|
||||
* @param topicId - The ID of the topic to close.
|
||||
* @param topicTitle - The title of the topic (for logging).
|
||||
* @returns Whether the topic was successfully closed.
|
||||
*/
|
||||
const closeTopic = async(
|
||||
topicId: number,
|
||||
topicTitle: string,
|
||||
): Promise<boolean> => {
|
||||
const url = `${discourseUrl}/t/${topicId.toString()}/status`;
|
||||
console.log(`Closing topic ${topicId.toString()}: ${topicTitle}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
status: "closed",
|
||||
}),
|
||||
headers: {
|
||||
...apiHeaders,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.log("Rate limited, waiting 5 seconds...");
|
||||
await sleep(5000);
|
||||
return await closeTopic(topicId, topicTitle);
|
||||
}
|
||||
console.error(
|
||||
`Failed to close topic ${topicId.toString()}: ${response.status.toString()} ${response.statusText}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Successfully closed topic ${topicId.toString()}: ${topicTitle}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Main execution
|
||||
const daysInactive = 28;
|
||||
|
||||
/**
|
||||
* Approximately 6 months in days.
|
||||
*/
|
||||
const daysSixMonths = 180;
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
|
||||
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setDate(sixMonthsAgo.getDate() - daysSixMonths);
|
||||
|
||||
console.log(`Fetching open topics (stopping at topics older than 6 months)...`);
|
||||
console.log(
|
||||
`Will close topics inactive for ${daysInactive.toString()}+ days but less than ${daysSixMonths.toString()} days old`,
|
||||
);
|
||||
console.log(
|
||||
`Cutoff date: ${cutoffDate.toISOString()} (topics inactive for ${daysInactive.toString()} days)`,
|
||||
);
|
||||
console.log(
|
||||
`Stop fetching at: ${sixMonthsAgo.toISOString()} (6 months ago)`,
|
||||
);
|
||||
|
||||
const openTopics = await fetchAllOpenTopics(sixMonthsAgo);
|
||||
|
||||
console.log(`\nChecking ${openTopics.length.toString()} open topics for inactivity...`);
|
||||
|
||||
const topicsToClose: Array<Topic> = [];
|
||||
|
||||
for (const topic of openTopics) {
|
||||
const lastActivityDate = getLastActivityDate(topic);
|
||||
|
||||
/**
|
||||
* Only close topics that are inactive for 28+ days but less than 6 months old.
|
||||
*/
|
||||
if (lastActivityDate < cutoffDate && lastActivityDate >= sixMonthsAgo) {
|
||||
topicsToClose.push(topic);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${topicsToClose.length.toString()} topics to close\n`);
|
||||
|
||||
if (topicsToClose.length === 0) {
|
||||
console.log("No topics need to be closed.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Close topics with rate limiting
|
||||
let closedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const topic of topicsToClose) {
|
||||
const success = await closeTopic(topic.id, topic.title);
|
||||
if (success) {
|
||||
closedCount = closedCount + 1;
|
||||
} else {
|
||||
failedCount = failedCount + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a small delay between requests to respect rate limits.
|
||||
* (backoffAndRetry handles 429s, but we want to avoid hitting them).
|
||||
*/
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
console.log(`\nCompleted:`);
|
||||
console.log(` Closed: ${closedCount.toString()}`);
|
||||
console.log(` Failed: ${failedCount.toString()}`);
|
||||
console.log(` Total: ${topicsToClose.length.toString()}`);
|
||||
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- The names of the properties match the API responses. */
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface CategoryList {
|
||||
category_list: {
|
||||
can_create_category: boolean;
|
||||
can_create_topic: boolean;
|
||||
categories: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
text_color: string;
|
||||
style_type: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
topic_count: number;
|
||||
post_count: number;
|
||||
position: number;
|
||||
description: string;
|
||||
description_text: string;
|
||||
description_excerpt: string;
|
||||
topic_url: string;
|
||||
read_restricted: boolean;
|
||||
permission: number;
|
||||
notification_level: number;
|
||||
can_edit: boolean;
|
||||
topic_template: string;
|
||||
has_children: boolean;
|
||||
subcategory_count: number;
|
||||
sort_order: string;
|
||||
sort_ascending: string;
|
||||
show_subcategory_list: boolean;
|
||||
num_featured_topics: number;
|
||||
default_view: string;
|
||||
subcategory_list_style: string;
|
||||
default_top_period: string;
|
||||
default_list_filter: string;
|
||||
minimum_required_tags: number;
|
||||
navigate_to_first_post_after_read: boolean;
|
||||
topics_day: number;
|
||||
topics_week: number;
|
||||
topics_month: number;
|
||||
topics_year: number;
|
||||
topics_all_time: number;
|
||||
is_uncategorized: boolean;
|
||||
subcategory_ids: Array<number>;
|
||||
subcategory_list: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
uploaded_logo: string;
|
||||
uploaded_logo_dark: string;
|
||||
uploaded_background: string;
|
||||
uploaded_background_dark: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoryGet {
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
text_color: string;
|
||||
style_type: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
topic_count: number;
|
||||
post_count: number;
|
||||
position: number;
|
||||
description: string;
|
||||
description_text: string;
|
||||
description_excerpt: string;
|
||||
topic_url: string;
|
||||
read_restricted: boolean;
|
||||
permission: number;
|
||||
notification_level: number;
|
||||
can_edit: boolean;
|
||||
topic_template: string;
|
||||
form_template_ids: Array<unknown>;
|
||||
has_children: boolean;
|
||||
subcategory_count: number;
|
||||
sort_order: string;
|
||||
sort_ascending: string;
|
||||
show_subcategory_list: boolean;
|
||||
num_featured_topics: number;
|
||||
default_view: string;
|
||||
subcategory_list_style: string;
|
||||
default_top_period: string;
|
||||
default_list_filter: string;
|
||||
minimum_required_tags: number;
|
||||
navigate_to_first_post_after_read: boolean;
|
||||
custom_fields: Record<string, unknown>;
|
||||
allowed_tags: Array<unknown>;
|
||||
allowed_tag_groups: Array<unknown>;
|
||||
allow_global_tags: boolean;
|
||||
required_tag_groups: Array<{
|
||||
name: string;
|
||||
min_count: number;
|
||||
}>;
|
||||
category_setting: {
|
||||
auto_bump_cooldown_days: number;
|
||||
num_auto_bump_daily: number;
|
||||
require_reply_approval: boolean;
|
||||
require_topic_approval: boolean;
|
||||
};
|
||||
category_localizations: Array<unknown>;
|
||||
read_only_banner: string;
|
||||
available_groups: Array<unknown>;
|
||||
auto_close_hours: string;
|
||||
auto_close_based_on_last_post: boolean;
|
||||
allow_unlimited_owner_edits_on_first_post: boolean;
|
||||
default_slow_mode_seconds: string;
|
||||
group_permissions: Array<{
|
||||
permission_type: number;
|
||||
group_name: string;
|
||||
group_id: number;
|
||||
}>;
|
||||
email_in: string;
|
||||
email_in_allow_strangers: boolean;
|
||||
mailinglist_mirror: boolean;
|
||||
all_topics_wiki: boolean;
|
||||
can_delete: boolean;
|
||||
allow_badges: boolean;
|
||||
topic_featured_link_allowed: boolean;
|
||||
search_priority: number;
|
||||
uploaded_logo: string;
|
||||
uploaded_logo_dark: string;
|
||||
uploaded_background: string;
|
||||
uploaded_background_dark: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
id: number;
|
||||
title: string;
|
||||
fancy_title: string;
|
||||
slug: string;
|
||||
posts_count: number;
|
||||
reply_count: number;
|
||||
highest_post_number: number;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
last_posted_at: string | null;
|
||||
bumped: boolean;
|
||||
bumped_at: string | null;
|
||||
archetype: string;
|
||||
unseen: boolean;
|
||||
pinned: boolean;
|
||||
unpinned: boolean | null;
|
||||
visible: boolean;
|
||||
closed: boolean;
|
||||
archived: boolean;
|
||||
bookmarked: boolean | null;
|
||||
liked: boolean | null;
|
||||
tags: Array<string>;
|
||||
tags_descriptions: Record<string, string>;
|
||||
like_count: number;
|
||||
views: number;
|
||||
category_id: number;
|
||||
featured_link: string | null;
|
||||
has_accepted_answer: boolean;
|
||||
posters: Array<{
|
||||
extras: string | null;
|
||||
description: string;
|
||||
user_id: number;
|
||||
primary_group_id: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TopicList {
|
||||
topic_list: {
|
||||
can_create_topic: boolean;
|
||||
draft: string | null;
|
||||
draft_key: string;
|
||||
draft_sequence: number;
|
||||
per_page: number;
|
||||
topics: Array<Topic>;
|
||||
};
|
||||
}
|
||||
|
||||
export type { CategoryList, CategoryGet, Topic, TopicList };
|
||||
@@ -9,23 +9,27 @@ import { sleep } from "./sleep.js";
|
||||
/**
|
||||
* Wraps the native fetch method in logic to back off
|
||||
* and retry on 429 errors.
|
||||
* @type {T} - The type of the response.
|
||||
* @param url - The URL to fetch.
|
||||
* @param options - The fetch options.
|
||||
* @returns The response, or null on error.
|
||||
*/
|
||||
export const backoffAndRetry
|
||||
= async(url: string, options: RequestInit = {}): Promise<void> => {
|
||||
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
await sleep(5000);
|
||||
await backoffAndRetry(url, options);
|
||||
return;
|
||||
return await backoffAndRetry(url, options);
|
||||
}
|
||||
throw new Error(`Request failed with status ${response.status.toString()}`);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user