diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..af3d700
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,389 @@
+root = true
+
+# All files
+[*]
+indent_style = space
+
+# Xml files
+[*.xml]
+indent_size = 2
+
+# Xml project files
+[*.{csproj,fsproj,vbproj,proj,slnx}]
+indent_size = 2
+
+# Xml config files
+[*.{props,targets,config,nuspec}]
+indent_size = 2
+
+[*.json]
+indent_size = 2
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig Options ####
+
+# Indentation and spacing
+indent_size = 4
+tab_width = 4
+
+# New line preferences
+insert_final_newline = false
+
+#### .NET Coding Conventions ####
+[*.{cs,vb}]
+
+# Organize usings
+dotnet_separate_import_directive_groups = true
+dotnet_sort_system_directives_first = true
+file_header_template = unset
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_property = false:silent
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:warning
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:suggestion
+
+# Suppression preferences
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+#### C# Coding Conventions ####
+[*.cs]
+
+# var preferences
+csharp_style_var_elsewhere = false:silent
+csharp_style_var_for_built_in_types = false:silent
+csharp_style_var_when_type_is_apparent = false:silent
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_prefer_switch_expression = true:suggestion
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_anonymous_function = true:suggestion
+csharp_prefer_static_local_function = true:warning
+csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+
+# Code-block preferences
+csharp_prefer_braces = true:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_top_level_statements = true:silent
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:silent
+
+#### C# Formatting Rules ####
+
+# New line preferences
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_switch_labels = true
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+[*.{cs,vb}]
+
+# Naming rules
+
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
+dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
+dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
+
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
+
+dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
+dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
+dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.events_should_be_pascalcase.symbols = events
+dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
+dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
+dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
+dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
+dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
+dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
+dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
+
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
+
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
+dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
+dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
+
+# Symbol specifications
+
+dotnet_naming_symbols.interfaces.applicable_kinds = interface
+dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interfaces.required_modifiers =
+
+dotnet_naming_symbols.enums.applicable_kinds = enum
+dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.enums.required_modifiers =
+
+dotnet_naming_symbols.events.applicable_kinds = event
+dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.events.required_modifiers =
+
+dotnet_naming_symbols.methods.applicable_kinds = method
+dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.methods.required_modifiers =
+
+dotnet_naming_symbols.properties.applicable_kinds = property
+dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.properties.required_modifiers =
+
+dotnet_naming_symbols.public_fields.applicable_kinds = field
+dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_fields.required_modifiers =
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_symbols.private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_fields.required_modifiers = static
+
+dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
+dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types_and_namespaces.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
+dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
+dotnet_naming_symbols.type_parameters.required_modifiers =
+
+dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.local_variables.applicable_kinds = local
+dotnet_naming_symbols.local_variables.applicable_accessibilities = local
+dotnet_naming_symbols.local_variables.required_modifiers =
+
+dotnet_naming_symbols.local_constants.applicable_kinds = local
+dotnet_naming_symbols.local_constants.applicable_accessibilities = local
+dotnet_naming_symbols.local_constants.required_modifiers = const
+
+dotnet_naming_symbols.parameters.applicable_kinds = parameter
+dotnet_naming_symbols.parameters.applicable_accessibilities = *
+dotnet_naming_symbols.parameters.required_modifiers =
+
+dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.local_functions.applicable_kinds = local_function
+dotnet_naming_symbols.local_functions.applicable_accessibilities = *
+dotnet_naming_symbols.local_functions.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascalcase.required_prefix =
+dotnet_naming_style.pascalcase.required_suffix =
+dotnet_naming_style.pascalcase.word_separator =
+dotnet_naming_style.pascalcase.capitalization = pascal_case
+
+dotnet_naming_style.ipascalcase.required_prefix = I
+dotnet_naming_style.ipascalcase.required_suffix =
+dotnet_naming_style.ipascalcase.word_separator =
+dotnet_naming_style.ipascalcase.capitalization = pascal_case
+
+dotnet_naming_style.tpascalcase.required_prefix = T
+dotnet_naming_style.tpascalcase.required_suffix =
+dotnet_naming_style.tpascalcase.word_separator =
+dotnet_naming_style.tpascalcase.capitalization = pascal_case
+
+dotnet_naming_style._camelcase.required_prefix = _
+dotnet_naming_style._camelcase.required_suffix =
+dotnet_naming_style._camelcase.word_separator =
+dotnet_naming_style._camelcase.capitalization = camel_case
+
+dotnet_naming_style.camelcase.required_prefix =
+dotnet_naming_style.camelcase.required_suffix =
+dotnet_naming_style.camelcase.word_separator =
+dotnet_naming_style.camelcase.capitalization = camel_case
+
+dotnet_naming_style.s_camelcase.required_prefix = s_
+dotnet_naming_style.s_camelcase.required_suffix =
+dotnet_naming_style.s_camelcase.word_separator =
+dotnet_naming_style.s_camelcase.capitalization = camel_case
+
diff --git a/.github/discord-bot/blocking.mjs b/.github/discord-bot/blocking.mjs
new file mode 100644
index 0000000..5f42d1e
--- /dev/null
+++ b/.github/discord-bot/blocking.mjs
@@ -0,0 +1,180 @@
+import { config } from './config.mjs'
+import {
+ getIssue,
+ nativeBlockedBy,
+ nativeBlocking,
+ searchTextDependents,
+} from './github.mjs'
+
+// "Blocked by", "blocked-by:", "depends on", "waiting on", "blocker:"
+const KEYWORD =
+ /(?:blocked[\s_-]*by|depends?[\s_-]*(?:up)?on|waiting[\s_-]*on|blockers?)\s*:?/gi
+
+// "#12", "owner/repo#12", or a full issue URL, anchored at the current position.
+const REF =
+ /^(?:(?:([\w.-]+)\/([\w.-]+))?#(\d+)|https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+))/i
+
+// Whitespace, commas, "and", and list/checkbox markers between consecutive refs.
+const SEPARATOR = /^(?:[\s,;&]+|and\b|[-*+]|\[[ xX]?\])+/i
+
+/**
+ * Pull blocker references out of an issue body.
+ * Only refs following a blocking keyword count, so a passing "see also #99"
+ * elsewhere in the description is not mistaken for a dependency.
+ */
+export function parseBlockerRefs(body, defaults) {
+ const text = String(body ?? '')
+ const out = new Map()
+ KEYWORD.lastIndex = 0
+
+ let keyword
+ while ((keyword = KEYWORD.exec(text)) !== null) {
+ let rest = text.slice(keyword.index + keyword[0].length)
+
+ for (;;) {
+ const gap = rest.match(SEPARATOR)
+ if (gap) rest = rest.slice(gap[0].length)
+
+ const ref = rest.match(REF)
+ if (!ref) break
+
+ const owner = ref[1] ?? ref[4] ?? defaults.owner
+ const repo = ref[2] ?? ref[5] ?? defaults.repo
+ const number = Number(ref[3] ?? ref[6])
+
+ const self =
+ number === defaults.number &&
+ owner.toLowerCase() === defaults.owner.toLowerCase() &&
+ repo.toLowerCase() === defaults.repo.toLowerCase()
+ if (!self)
+ out.set(`${owner}/${repo}#${number}`.toLowerCase(), {
+ owner,
+ repo,
+ number,
+ })
+
+ rest = rest.slice(ref[0].length)
+ if (!SEPARATOR.test(rest)) break
+ }
+ }
+ return [...out.values()]
+}
+
+export function hasBlockedLabel(issue) {
+ const labels = (issue?.labels ?? []).map((l) =>
+ String(l.name ?? l).toLowerCase(),
+ )
+ return labels.some((name) => config.blockedLabels.includes(name))
+}
+
+/**
+ * Everything standing between an issue and someone starting work on it:
+ * native GitHub dependencies, "blocked by #N" text, and blocking labels.
+ */
+export async function getBlockState(owner, repo, issue) {
+ const number = issue.number
+ const blockers = new Map()
+
+ for (const dep of await nativeBlockedBy(owner, repo, number)) {
+ const key =
+ `${dep.repository?.full_name ?? `${owner}/${repo}`}#${dep.number}`.toLowerCase()
+ blockers.set(key, {
+ number: dep.number,
+ title: dep.title,
+ state: dep.state,
+ html_url: dep.html_url,
+ })
+ }
+
+ for (const ref of parseBlockerRefs(issue.body, { owner, repo, number })) {
+ const key = `${ref.owner}/${ref.repo}#${ref.number}`.toLowerCase()
+ if (blockers.has(key)) continue
+ const target = await getIssue(ref.owner, ref.repo, ref.number)
+ if (!target) continue // deleted or unreadable — do not block on a ghost
+ blockers.set(key, {
+ number: target.number,
+ title: target.title,
+ state: target.state,
+ html_url: target.html_url,
+ })
+ }
+
+ const all = [...blockers.values()]
+ const open = all.filter((b) => b.state === 'open')
+ const labelBlocked = hasBlockedLabel(issue)
+
+ return {
+ blockers: all,
+ openBlockers: open,
+ labelBlocked,
+ blocked: open.length > 0 || labelBlocked,
+ }
+}
+
+/** Issues that were waiting on this one — natively linked and text-linked. */
+export async function findDependents(owner, repo, number) {
+ const found = new Map()
+
+ for (const dep of await nativeBlocking(owner, repo, number)) {
+ const full = dep.repository?.full_name ?? `${owner}/${repo}`
+ const [o, r] = full.split('/')
+ found.set(`${full}#${dep.number}`.toLowerCase(), {
+ owner: o,
+ repo: r,
+ number: dep.number,
+ })
+ }
+
+ for (const item of await searchTextDependents(owner, repo, number)) {
+ found.set(`${owner}/${repo}#${item.number}`.toLowerCase(), {
+ owner,
+ repo,
+ number: item.number,
+ })
+ }
+
+ return [...found.values()]
+}
+
+/**
+ * How many still-open issues are waiting on this one.
+ *
+ * Uses only the native dependency links — the text search behind findDependents costs
+ * scarce search-API quota, which is too expensive to spend per issue in a digest.
+ */
+export async function countBlocking(owner, repo, number) {
+ try {
+ const deps = await nativeBlocking(owner, repo, number)
+ return deps.filter((d) => d.state === 'open').length
+ } catch (err) {
+ // Unknown is not zero. Callers omit the claim rather than assert "blocks nothing".
+ console.warn(
+ `[blocking] could not count dependents of #${number}: ${err.message}`,
+ )
+ return null
+ }
+}
+
+/** "blocking 3 other issues", or "" for zero and for unknown. */
+export function blockingPhrase(count) {
+ if (!count) return ''
+ return `blocking ${count} other issue${count === 1 ? '' : 's'}`
+}
+
+/** Embed field value for a blocking count, honest about a failed lookup. */
+export function blockingFieldValue(count) {
+ if (count === null) return 'could not check'
+ return count > 0
+ ? `⛔ ${count} other open issue${count === 1 ? '' : 's'}`
+ : 'nothing'
+}
+
+export function formatBlockerList(blockers) {
+ if (blockers.length === 0) return 'none'
+ return blockers
+ .map(
+ (b) =>
+ `[#${b.number}](${b.html_url}) ${String(b.title ?? '').slice(0, 60)}`,
+ )
+ .join('\n')
+}
diff --git a/.github/discord-bot/config.mjs b/.github/discord-bot/config.mjs
new file mode 100644
index 0000000..ab25713
--- /dev/null
+++ b/.github/discord-bot/config.mjs
@@ -0,0 +1,128 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+
+const list = (v) =>
+ (v ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+
+export const config = {
+ discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || '',
+ discordReviewWebhookUrl:
+ process.env.DISCORD_REVIEW_WEBHOOK_URL ||
+ process.env.DISCORD_WEBHOOK_URL ||
+ '',
+ githubToken: process.env.GITHUB_TOKEN || '',
+ blockedLabels: list(process.env.BLOCKED_LABELS || 'blocked').map((s) =>
+ s.toLowerCase(),
+ ),
+ // Set ALLOW_EVERYONE=false in a busy server: team-wide messages then name each
+ // mapped teammate individually instead of firing a real @everyone.
+ allowEveryone:
+ (process.env.ALLOW_EVERYONE || 'true').toLowerCase() !== 'false',
+}
+
+/**
+ * GitHub login -> Discord user id.
+ *
+ * Read from the DISCORD_USER_MAP secret when set, so teammates' Discord ids stay out
+ * of a public repo. Falls back to a committed usermap.json for teams that would
+ * rather edit a file than a secret.
+ */
+function loadUserMap() {
+ let raw = null
+
+ if (process.env.DISCORD_USER_MAP) {
+ try {
+ raw = JSON.parse(process.env.DISCORD_USER_MAP)
+ } catch (err) {
+ console.error(
+ '::error::DISCORD_USER_MAP secret is not valid JSON:',
+ err.message,
+ )
+ }
+ }
+
+ if (!raw) {
+ const file = path.join(here, 'usermap.json')
+ try {
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'))
+ } catch {
+ raw = {}
+ }
+ }
+
+ const map = {}
+ for (const [login, id] of Object.entries(raw)) {
+ if (login.startsWith('_')) continue // comment keys
+ map[login.toLowerCase()] = String(id)
+ }
+ return map
+}
+
+let userMap = null
+const getUserMap = () => (userMap ??= loadUserMap())
+
+/**
+ * A person, named the way Discord should show them: `@TheirName (their-github-login)`.
+ *
+ * The Discord mention alone renders as a display name that often looks nothing like
+ * the GitHub account it belongs to, which makes "who is that?" a real question on a
+ * team message. Carrying the login in brackets answers it without a second lookup.
+ *
+ * Only a mention that also appears in allowed_mentions actually notifies anyone, so
+ * this is safe to use for third parties we merely want to name — they render as a
+ * highlighted mention but stay silent unless the caller pings them on purpose.
+ *
+ * Someone missing from the user map has no Discord name to show, so their GitHub
+ * handle stands alone.
+ */
+export function mention(login) {
+ if (!login) return 'someone'
+ const id = getUserMap()[String(login).toLowerCase()]
+ return id ? `<@${id}> (${login})` : `**@${login}**`
+}
+
+/** Discord ids for allowed_mentions — the allowlist of who this message may ping. */
+export function mentionIds(logins) {
+ const map = getUserMap()
+ return [
+ ...new Set(
+ logins.map((l) => map[String(l ?? '').toLowerCase()]).filter(Boolean),
+ ),
+ ]
+}
+
+/**
+ * How to address the whole team in one line.
+ *
+ * `@everyone` when the server allows it, otherwise every mapped teammate by name —
+ * the same intent, delivered the only other way we can deliver it. Returns the
+ * logins that must be allowlisted so the fallback actually notifies anyone.
+ */
+export function everyoneMention() {
+ if (config.allowEveryone) return { text: '@everyone', logins: [] }
+ const logins = Object.keys(getUserMap())
+ return { text: logins.map(mention).join(' ') || 'everyone', logins }
+}
+
+export function assertConfig() {
+ if (!config.discordWebhookUrl) {
+ console.error(
+ '::error::DISCORD_WEBHOOK_URL is not set. Add it as a repository secret.',
+ )
+ process.exit(1)
+ }
+ const mapped = Object.keys(getUserMap()).length
+ console.log(`[config] ${mapped} GitHub login(s) mapped to Discord ids`)
+ if (mapped === 0) {
+ console.warn(
+ '::warning::No user map found — people will be named in messages but not pinged. ' +
+ 'Set the DISCORD_USER_MAP secret or commit .github/discord-bot/usermap.json.',
+ )
+ }
+}
diff --git a/.github/discord-bot/discord.mjs b/.github/discord-bot/discord.mjs
new file mode 100644
index 0000000..15851c8
--- /dev/null
+++ b/.github/discord-bot/discord.mjs
@@ -0,0 +1,70 @@
+import { config, mentionIds } from './config.mjs'
+
+export const COLORS = {
+ unblocked: 0x2ea043, // green — go
+ ready: 0x1f6feb, // blue — assigned and actionable
+ review: 0xd29922, // amber — needs a human
+ merged: 0x8957e5, // purple — GitHub's merge colour
+ blocked: 0x8b949e, // grey — informational
+}
+
+/**
+ * Post one message to a Discord channel webhook.
+ *
+ * `pingLogins` are GitHub logins; only their mapped Discord ids end up in
+ * allowed_mentions, so an @everyone typed into an issue title stays inert.
+ *
+ * `everyone: true` is the one way to make a real @everyone fire, and only the bot's
+ * own message text can trigger it — never text copied out of GitHub. Reserved for
+ * things the whole team has to see: sprint progress, and work nobody has picked up.
+ */
+export async function sendDiscord({
+ content,
+ embed,
+ pingLogins = [],
+ review = false,
+ everyone = false,
+}) {
+ const url = review ? config.discordReviewWebhookUrl : config.discordWebhookUrl
+ if (!url) return
+
+ const body = {
+ content,
+ embeds: embed ? [embed] : undefined,
+ allowed_mentions: {
+ parse: everyone && config.allowEveryone ? ['everyone'] : [],
+ users: mentionIds(pingLogins),
+ },
+ }
+
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+
+ if (res.status === 429) {
+ const retry = Number(res.headers.get('retry-after') || 1)
+ console.warn(`[discord] rate limited, waiting ${retry}s`)
+ await new Promise((r) => setTimeout(r, retry * 1000 + 250))
+ continue
+ }
+
+ if (!res.ok) {
+ console.error(
+ `::error::Discord returned ${res.status}: ${await res.text()}`,
+ )
+ return
+ }
+
+ console.log(`[discord] sent: ${content.slice(0, 90)}`)
+ return
+ }
+ console.error('::error::Discord kept rate limiting; message dropped.')
+}
+
+export function clamp(text, max = 300) {
+ const s = String(text ?? '').trim()
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`
+}
diff --git a/.github/discord-bot/github.mjs b/.github/discord-bot/github.mjs
new file mode 100644
index 0000000..4251183
--- /dev/null
+++ b/.github/discord-bot/github.mjs
@@ -0,0 +1,173 @@
+import { config } from './config.mjs'
+
+// Actions sets GITHUB_API_URL itself; honouring it also lets the tests point the
+// whole client at a local fixture server instead of the network.
+const API = process.env.GITHUB_API_URL || 'https://api.github.com'
+
+/** Raised when a request could not be completed, as opposed to legitimately empty. */
+export class GitHubUnavailable extends Error {
+ constructor(path, status) {
+ super(`GitHub returned ${status} for ${path} after retries`)
+ this.name = 'GitHubUnavailable'
+ this.status = status
+ }
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+/**
+ * Minimal GitHub client over fetch — deliberately dependency-free so the workflow
+ * needs no `npm install` step and cannot break on a transitive update.
+ *
+ * Distinguishes "this does not exist" (null) from "I could not find out"
+ * (throws GitHubUnavailable). The dependency endpoints return intermittent 504s,
+ * and treating one as "no dependencies" would put a false statement in a message.
+ */
+async function gh(path, { attempts = 3 } = {}) {
+ let lastStatus = 0
+
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ let res
+ try {
+ res = await fetch(`${API}${path}`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ 'x-github-api-version': '2022-11-28',
+ 'user-agent': 'moisson-discord-bot',
+ ...(config.githubToken
+ ? { authorization: `Bearer ${config.githubToken}` }
+ : {}),
+ },
+ })
+ } catch (err) {
+ // Network-level failure: worth retrying.
+ lastStatus = 0
+ if (attempt < attempts) {
+ await sleep(500 * attempt)
+ continue
+ }
+ throw new GitHubUnavailable(path, `network error (${err.message})`)
+ }
+
+ // Genuinely absent, or an endpoint this repo does not have. Not a failure.
+ if (res.status === 404 || res.status === 410) return null
+
+ // Rate limited or a secondary limit — back off and retry.
+ if (
+ res.status === 429 ||
+ (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0')
+ ) {
+ lastStatus = res.status
+ const reset = Number(res.headers.get('x-ratelimit-reset') || 0) * 1000
+ const wait =
+ reset > Date.now()
+ ? Math.min(reset - Date.now(), 15_000)
+ : 1000 * attempt
+ if (attempt < attempts) {
+ console.warn(
+ `[github] rate limited on ${path}, waiting ${Math.round(wait / 1000)}s`,
+ )
+ await sleep(wait)
+ continue
+ }
+ throw new GitHubUnavailable(path, res.status)
+ }
+
+ // Permission denied with quota left is a real answer: we cannot see this.
+ if (res.status === 403) return null
+
+ // 5xx — GitHub had a bad moment. Retry before believing it.
+ if (res.status >= 500) {
+ lastStatus = res.status
+ if (attempt < attempts) {
+ console.warn(
+ `[github] ${res.status} on ${path}, retry ${attempt}/${attempts - 1}`,
+ )
+ await sleep(600 * attempt)
+ continue
+ }
+ throw new GitHubUnavailable(path, res.status)
+ }
+
+ if (!res.ok) throw new GitHubUnavailable(path, res.status)
+
+ return res.json()
+ }
+
+ throw new GitHubUnavailable(path, lastStatus)
+}
+
+export async function getIssue(owner, repo, number) {
+ return gh(`/repos/${owner}/${repo}/issues/${number}`)
+}
+
+/** Issues that must close before this one can start (GitHub's native dependencies). */
+export async function nativeBlockedBy(owner, repo, number) {
+ const data = await gh(
+ `/repos/${owner}/${repo}/issues/${number}/dependencies/blocked_by?per_page=100`,
+ )
+ return Array.isArray(data) ? data : []
+}
+
+/** The inverse: issues waiting on this one. */
+export async function nativeBlocking(owner, repo, number) {
+ const data = await gh(
+ `/repos/${owner}/${repo}/issues/${number}/dependencies/blocking?per_page=100`,
+ )
+ return Array.isArray(data) ? data : []
+}
+
+/** Open issues whose body names this one as a blocker in plain text. */
+export async function searchTextDependents(owner, repo, number) {
+ const found = new Map()
+ for (const phrase of ['blocked by', 'depends on']) {
+ const q = encodeURIComponent(
+ `repo:${owner}/${repo} is:issue is:open in:body "${phrase} #${number}"`,
+ )
+ const data = await gh(`/search/issues?q=${q}&per_page=50`)
+ for (const item of data?.items ?? []) {
+ if (item.pull_request) continue
+ found.set(item.number, item)
+ }
+ }
+ return [...found.values()]
+}
+
+/**
+ * Paginated issue listing. Pull requests are filtered out — GitHub's issues
+ * endpoint returns them too, and a PR is never a task on a sprint board.
+ */
+async function listIssues(owner, repo, query) {
+ const out = []
+ for (let page = 1; page <= 10; page++) {
+ const data = await gh(
+ `/repos/${owner}/${repo}/issues?per_page=100&page=${page}&${query}`,
+ )
+ if (!Array.isArray(data) || data.length === 0) break
+ out.push(...data.filter((i) => !i.pull_request))
+ if (data.length < 100) break
+ }
+ return out
+}
+
+export async function listOpenIssues(owner, repo) {
+ return listIssues(owner, repo, 'state=open')
+}
+
+/** Every issue filed against one milestone — closed ones included, for the ratio. */
+export async function listMilestoneIssues(
+ owner,
+ repo,
+ milestone,
+ state = 'all',
+) {
+ return listIssues(owner, repo, `state=${state}&milestone=${milestone}`)
+}
+
+/** Sprints. Sorted by due date so the nearest deadline comes first. */
+export async function listMilestones(owner, repo, state = 'open') {
+ const data = await gh(
+ `/repos/${owner}/${repo}/milestones?state=${state}&per_page=100&sort=due_on&direction=asc`,
+ )
+ return Array.isArray(data) ? data : []
+}
diff --git a/.github/discord-bot/handlers/issues.mjs b/.github/discord-bot/handlers/issues.mjs
new file mode 100644
index 0000000..fa3291f
--- /dev/null
+++ b/.github/discord-bot/handlers/issues.mjs
@@ -0,0 +1,180 @@
+import { COLORS, clamp, sendDiscord } from '../discord.mjs'
+import { getIssue } from '../github.mjs'
+import {
+ blockingFieldValue,
+ blockingPhrase,
+ countBlocking,
+ findDependents,
+ formatBlockerList,
+ getBlockState,
+ parseBlockerRefs,
+} from '../blocking.mjs'
+import { config, mention } from '../config.mjs'
+
+const assigneeLogins = (issue) =>
+ (issue.assignees ?? []).map((u) => u.login).filter(Boolean)
+
+/** The headline feature: an issue that was waiting on something is now workable. */
+async function announceUnblocked(owner, repo, issue, { trigger, block }) {
+ if (issue.state !== 'open') return
+
+ block ??= await getBlockState(owner, repo, issue)
+ if (block.blocked) return
+
+ const assignees = assigneeLogins(issue)
+ const who = assignees.length
+ ? assignees.map(mention).join(' ')
+ : '**Up for grabs** — nobody is assigned'
+
+ // How much else is waiting on this decides how urgent it is.
+ const blocking = await countBlocking(owner, repo, issue.number)
+ const urgency = blocking ? ` It's ${blockingPhrase(blocking)}.` : ''
+
+ await sendDiscord({
+ content: `${who} — issue #${issue.number} is unblocked. You can start on it.${urgency}`,
+ pingLogins: assignees,
+ embed: {
+ title: `#${issue.number} ${clamp(issue.title, 200)}`,
+ url: issue.html_url,
+ color: COLORS.unblocked,
+ description: trigger
+ ? `Unblocked by ${trigger}.`
+ : 'All blockers are resolved.',
+ fields: [
+ {
+ name: 'Was blocked by',
+ value: block.blockers.length
+ ? clamp(formatBlockerList(block.blockers), 800)
+ : 'a blocking label',
+ },
+ { name: 'Blocking', value: blockingFieldValue(blocking), inline: true },
+ {
+ name: 'Assigned to',
+ value: assignees.length ? assignees.join(', ') : 'nobody',
+ inline: true,
+ },
+ ],
+ timestamp: new Date().toISOString(),
+ },
+ })
+}
+
+/** Someone got assigned — tell them whether they can actually start. */
+async function onAssigned(owner, repo, issue, payload) {
+ const target = payload.assignee?.login
+ const block = await getBlockState(owner, repo, issue)
+ const blocking = await countBlocking(owner, repo, issue.number)
+
+ const blockingField = {
+ name: 'Blocking',
+ value: blockingFieldValue(blocking),
+ inline: true,
+ }
+
+ if (!block.blocked) {
+ const urgency = blocking
+ ? ` Worth prioritising — it's ${blockingPhrase(blocking)}.`
+ : ''
+ await sendDiscord({
+ content: `${mention(target)} — you're assigned to #${issue.number} and nothing is blocking it. Ready to work.${urgency}`,
+ pingLogins: [target],
+ embed: {
+ title: `#${issue.number} ${clamp(issue.title, 200)}`,
+ url: issue.html_url,
+ color: COLORS.ready,
+ description: clamp(issue.body, 300) || '_No description._',
+ fields: [
+ blockingField,
+ { name: 'Repo', value: `${owner}/${repo}`, inline: true },
+ ],
+ timestamp: new Date().toISOString(),
+ },
+ })
+ return
+ }
+
+ await sendDiscord({
+ content: `${mention(target)} — you're assigned to #${issue.number}, but it's blocked. Nothing to do yet; I'll ping you the moment it clears.`,
+ pingLogins: [target],
+ embed: {
+ title: `#${issue.number} ${clamp(issue.title, 200)}`,
+ url: issue.html_url,
+ color: COLORS.blocked,
+ fields: [
+ {
+ name: 'Waiting on',
+ value: block.openBlockers.length
+ ? clamp(formatBlockerList(block.openBlockers), 800)
+ : 'a blocking label',
+ },
+ blockingField,
+ ],
+ timestamp: new Date().toISOString(),
+ },
+ })
+}
+
+/** A closed issue can free everything downstream of it. */
+async function onClosed(owner, repo, issue) {
+ const dependents = await findDependents(owner, repo, issue.number)
+ console.log(
+ `[issues] #${issue.number} closed; ${dependents.length} dependent(s) to re-check`,
+ )
+
+ for (const dep of dependents) {
+ const full = await getIssue(dep.owner, dep.repo, dep.number)
+ if (!full || full.state !== 'open') continue
+ await announceUnblocked(dep.owner, dep.repo, full, {
+ trigger: `[#${issue.number}](${issue.html_url}) closing`,
+ })
+ }
+}
+
+export async function handleIssues(payload) {
+ const { action, issue, repository } = payload
+ const owner = repository.owner.login
+ const repo = repository.name
+
+ switch (action) {
+ case 'assigned':
+ return onAssigned(owner, repo, issue, payload)
+
+ case 'closed':
+ return onClosed(owner, repo, issue)
+
+ case 'unlabeled':
+ case 'edited': {
+ // Blocked-ness can change in place: a `blocked` label removed, or
+ // "Blocked by #12" edited out of the body.
+ const block = await getBlockState(owner, repo, issue)
+ if (block.blocked) return
+
+ // Only announce when this edit is what removed the block — otherwise an
+ // unrelated typo fix on a never-blocked issue would read as good news.
+ const removedLabel =
+ action === 'unlabeled' &&
+ config.blockedLabels.includes(
+ String(payload.label?.name ?? '').toLowerCase(),
+ )
+ const removedText =
+ action === 'edited' &&
+ parseBlockerRefs(payload.changes?.body?.from, {
+ owner,
+ repo,
+ number: issue.number,
+ }).length > 0
+
+ if (!removedLabel && !removedText) return
+
+ return announceUnblocked(owner, repo, issue, {
+ block,
+ trigger: removedLabel
+ ? `the \`${payload.label.name}\` label being removed`
+ : 'its blockers being edited out',
+ })
+ }
+
+ default:
+ return
+ }
+}
diff --git a/.github/discord-bot/handlers/pull_request.mjs b/.github/discord-bot/handlers/pull_request.mjs
new file mode 100644
index 0000000..b671ce8
--- /dev/null
+++ b/.github/discord-bot/handlers/pull_request.mjs
@@ -0,0 +1,177 @@
+import { COLORS, clamp, sendDiscord } from '../discord.mjs'
+import { mention } from '../config.mjs'
+
+function prEmbed(pr, repository, color, extraFields = []) {
+ return {
+ title: `#${pr.number} ${clamp(pr.title, 200)}`,
+ url: pr.html_url,
+ color,
+ author: pr.user
+ ? { name: pr.user.login, icon_url: pr.user.avatar_url }
+ : undefined,
+ fields: [
+ {
+ name: 'Branch',
+ value: `\`${pr.head?.ref}\` → \`${pr.base?.ref}\``,
+ inline: true,
+ },
+ { name: 'Repo', value: repository.full_name, inline: true },
+ ...extraFields,
+ ],
+ timestamp: new Date().toISOString(),
+ }
+}
+
+const size = (pr) =>
+ `+${pr.additions ?? '?'} / -${pr.deletions ?? '?'} across ${pr.changed_files ?? '?'} files`
+
+/** Issues this PR will close when it lands, as written in the description. */
+const closesIssues = (pr) =>
+ (pr.body ?? '').match(/(?:close[sd]?|fixe?[sd]?|resolve[sd]?)\s+#(\d+)/gi) ??
+ []
+
+/**
+ * A PR has just appeared and wants eyes on it.
+ *
+ * Drafts are skipped on purpose — a draft is the author saying "not yet". They are
+ * announced later instead, when the author marks the PR ready for review.
+ *
+ * Reviewers set at creation time get a real ping. A PR opened with nobody on it is
+ * still announced, because a PR no one knows about is exactly the one that rots, but
+ * it pings nobody — there is no one to ping yet.
+ */
+async function onOpened(payload) {
+ const { pull_request: pr, repository } = payload
+ if (pr.draft) {
+ console.log(
+ `[notify] #${pr.number} is a draft — staying quiet until it is ready`,
+ )
+ return
+ }
+
+ const author = pr.user?.login
+ const reviewers = (pr.requested_reviewers ?? [])
+ .map((r) => r.login)
+ .filter(Boolean)
+ const teams = (pr.requested_teams ?? []).map((t) => t.name).filter(Boolean)
+ const closes = closesIssues(pr)
+
+ const asked = [...reviewers.map(mention), ...teams.map((t) => `**@${t}**`)]
+ const opened =
+ payload.action === 'ready_for_review' ? 'is ready for review' : 'is open'
+
+ await sendDiscord({
+ review: true,
+ content: asked.length
+ ? `${asked.join(' ')} — PR #${pr.number} from ${mention(author)} ${opened} and needs your review.`
+ : `PR #${pr.number} from ${mention(author)} ${opened} — no reviewer assigned yet.`,
+ pingLogins: reviewers,
+ embed: prEmbed(
+ pr,
+ repository,
+ asked.length ? COLORS.review : COLORS.ready,
+ [
+ { name: 'Size', value: size(pr), inline: true },
+ {
+ name: 'Reviewers',
+ value: asked.length ? asked.join(', ') : '_nobody yet_',
+ inline: true,
+ },
+ ...(closes.length
+ ? [{ name: 'Closes', value: closes.join(', ') }]
+ : []),
+ ],
+ ),
+ })
+}
+
+/** Someone was put on the hook for a review. */
+async function onReviewRequested(payload) {
+ const { pull_request: pr, repository } = payload
+ const reviewer = payload.requested_reviewer?.login
+ const team = payload.requested_team?.name
+ const author = pr.user?.login ?? 'someone'
+
+ await sendDiscord({
+ review: true,
+ content: `${reviewer ? mention(reviewer) : `**@${team}**`} — code review requested by ${mention(author)} on #${pr.number}.`,
+ pingLogins: reviewer ? [reviewer] : [],
+ embed: prEmbed(pr, repository, COLORS.review, [
+ { name: 'Size', value: size(pr), inline: true },
+ {
+ name: 'Reviewer',
+ value: reviewer ? mention(reviewer) : `team @${team}`,
+ inline: true,
+ },
+ ]),
+ })
+}
+
+/** Merged, not merely closed — an abandoned PR is not worth a celebration. */
+async function onClosed(payload) {
+ const { pull_request: pr, repository } = payload
+ if (!pr.merged) return
+
+ const author = pr.user?.login
+ const merger = payload.sender?.login
+ const closes = closesIssues(pr)
+
+ await sendDiscord({
+ content: `${mention(author)} — your PR #${pr.number} was merged into \`${pr.base?.ref}\` by ${mention(merger)}. 🎉`,
+ pingLogins: [...new Set([author, merger].filter(Boolean))],
+ embed: prEmbed(pr, repository, COLORS.merged, [
+ ...(closes.length ? [{ name: 'Closes', value: closes.join(', ') }] : []),
+ { name: 'Merged', value: size(pr), inline: true },
+ ]),
+ })
+}
+
+export async function handlePullRequest(payload) {
+ switch (payload.action) {
+ case 'opened':
+ case 'ready_for_review':
+ return onOpened(payload)
+ case 'review_requested':
+ return onReviewRequested(payload)
+ case 'closed':
+ return onClosed(payload)
+ default:
+ return
+ }
+}
+
+/** A submitted review is news for the PR author, not the reviewer. */
+export async function handlePullRequestReview(payload) {
+ if (payload.action !== 'submitted') return
+
+ const { review, pull_request: pr, repository } = payload
+ const author = pr.user?.login
+ const reviewer = review.user?.login
+
+ const line = {
+ approved: {
+ text: `${mention(author)} — ${mention(reviewer)} approved #${pr.number}. Good to merge.`,
+ color: COLORS.unblocked,
+ },
+ changes_requested: {
+ text: `${mention(author)} — ${mention(reviewer)} requested changes on #${pr.number}.`,
+ color: COLORS.review,
+ },
+ }[String(review.state ?? '').toLowerCase()]
+
+ if (!line) return // plain comments would just be noise
+
+ await sendDiscord({
+ review: true,
+ content: line.text,
+ // The reviewer is pinged as well as the author: they asked for a change and will
+ // want the thread in their notifications when the revision lands.
+ pingLogins: [...new Set([author, reviewer].filter(Boolean))],
+ embed: prEmbed(pr, repository, line.color, [
+ {
+ name: 'Review',
+ value: clamp(review.body, 500) || '_No comment left._',
+ },
+ ]),
+ })
+}
diff --git a/.github/discord-bot/notify.mjs b/.github/discord-bot/notify.mjs
new file mode 100644
index 0000000..77fd048
--- /dev/null
+++ b/.github/discord-bot/notify.mjs
@@ -0,0 +1,49 @@
+/**
+ * Entry point for the GitHub Actions workflow.
+ *
+ * Actions hands us the event name in GITHUB_EVENT_NAME and the full webhook payload
+ * as a file at GITHUB_EVENT_PATH — the same payload a webhook server would receive,
+ * minus the transport, the signature check, and the hosting.
+ */
+import fs from 'node:fs'
+import { assertConfig } from './config.mjs'
+import { handleIssues } from './handlers/issues.mjs'
+import {
+ handlePullRequest,
+ handlePullRequestReview,
+} from './handlers/pull_request.mjs'
+
+assertConfig()
+
+const eventName = process.env.GITHUB_EVENT_NAME
+const eventPath = process.env.GITHUB_EVENT_PATH
+
+if (!eventName || !eventPath) {
+ console.error('::error::GITHUB_EVENT_NAME / GITHUB_EVENT_PATH are missing.')
+ process.exit(1)
+}
+
+const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'))
+console.log(
+ `[notify] ${eventName}.${payload.action ?? '-'} on ${payload.repository?.full_name}`,
+)
+
+try {
+ switch (eventName) {
+ case 'issues':
+ await handleIssues(payload)
+ break
+ case 'pull_request':
+ case 'pull_request_target':
+ await handlePullRequest(payload)
+ break
+ case 'pull_request_review':
+ await handlePullRequestReview(payload)
+ break
+ default:
+ console.log(`[notify] nothing to do for ${eventName}`)
+ }
+} catch (err) {
+ // A failed notification must never turn the repo's checks red.
+ console.error(`::warning::notification failed: ${err.stack || err.message}`)
+}
diff --git a/.github/discord-bot/reminders.mjs b/.github/discord-bot/reminders.mjs
new file mode 100644
index 0000000..ad6d71c
--- /dev/null
+++ b/.github/discord-bot/reminders.mjs
@@ -0,0 +1,143 @@
+/**
+ * Scheduled nudge for work that has gone quiet.
+ *
+ * Run on a cron by .github/workflows/discord-reminders.yml. Posts one digest that
+ * pings each person with their open, assigned, unblocked issues that have seen no
+ * recent activity.
+ *
+ * Deliberately silent about:
+ * - blocked issues — nagging someone about work they cannot start is noise
+ * - unassigned issues — there is nobody to remind
+ * - recently touched work — an issue commented on this morning is not stale
+ *
+ * Silence is the intended output when everyone is current.
+ */
+import { assertConfig, mention } from './config.mjs'
+import { COLORS, clamp, sendDiscord } from './discord.mjs'
+import { blockingPhrase, countBlocking, getBlockState } from './blocking.mjs'
+import { listOpenIssues } from './github.mjs'
+
+assertConfig()
+
+const repoFullName = process.env.GITHUB_REPOSITORY
+if (!repoFullName) {
+ console.error('::error::GITHUB_REPOSITORY is not set.')
+ process.exit(1)
+}
+const [owner, repo] = repoFullName.split('/')
+
+const staleDays = Number(process.env.STALE_DAYS || 2)
+const now = Date.now()
+const daysSince = (iso) => (now - new Date(iso).getTime()) / 86_400_000
+
+const issues = await listOpenIssues(owner, repo)
+console.log(`[reminders] ${issues.length} open issues in ${repoFullName}`)
+
+/** login -> issues that person could be working on right now but has not touched. */
+const byAssignee = new Map()
+let skippedBlocked = 0
+let skippedFresh = 0
+let skippedUnassigned = 0
+let unreadable = 0
+
+for (const issue of issues) {
+ const assignees = (issue.assignees ?? []).map((a) => a.login).filter(Boolean)
+ if (assignees.length === 0) {
+ skippedUnassigned++
+ continue
+ }
+
+ const idle = daysSince(issue.updated_at)
+ if (idle < staleDays) {
+ skippedFresh++
+ continue
+ }
+
+ // One unreachable issue must not sink the whole digest.
+ let block
+ try {
+ block = await getBlockState(owner, repo, issue)
+ } catch (err) {
+ console.warn(`[reminders] skipping #${issue.number}: ${err.message}`)
+ unreadable++
+ continue
+ }
+ if (block.blocked) {
+ skippedBlocked++
+ continue
+ }
+
+ const blocking = (await countBlocking(owner, repo, issue.number)) ?? 0
+
+ for (const login of assignees) {
+ if (!byAssignee.has(login)) byAssignee.set(login, [])
+ byAssignee.get(login).push({ issue, idle, blocking })
+ }
+}
+
+console.log(
+ `[reminders] ${byAssignee.size} person(s) with stale work; ` +
+ `skipped ${skippedUnassigned} unassigned, ${skippedFresh} recently active, ${skippedBlocked} blocked` +
+ (unreadable > 0 ? `, ${unreadable} unreadable` : ''),
+)
+
+if (byAssignee.size === 0) {
+ console.log('[reminders] nothing to nag about — staying quiet')
+ process.exit(0)
+}
+
+// People holding up the most work lead the digest; idle time breaks ties.
+const heldUp = (rows) => rows.reduce((n, r) => n + r.blocking, 0)
+const sorted = [...byAssignee.entries()].sort((a, b) => {
+ const diff = heldUp(b[1]) - heldUp(a[1])
+ if (diff !== 0) return diff
+ return (
+ Math.max(...b[1].map((x) => x.idle)) - Math.max(...a[1].map((x) => x.idle))
+ )
+})
+
+const fields = sorted.slice(0, 25).map(([login, rows]) => {
+ const held = heldUp(rows)
+ return {
+ name: `@${login} — ${rows.length} open${held > 0 ? `, ${blockingPhrase(held)}` : ''}`,
+ value: clamp(
+ rows
+ .sort((a, b) => b.blocking - a.blocking || b.idle - a.idle)
+ .map(
+ ({ issue, idle, blocking }) =>
+ `[#${issue.number}](${issue.html_url}) ${String(issue.title).slice(0, 44)}` +
+ ` — quiet ${Math.floor(idle)}d${blocking > 0 ? ` · ⛔ ${blocking}` : ''}`,
+ )
+ .join('\n'),
+ 1000,
+ ),
+ }
+})
+
+const total = sorted.reduce((n, [, rows]) => n + rows.length, 0)
+const totalBlocking = sorted.reduce((n, [, rows]) => n + heldUp(rows), 0)
+
+await sendDiscord({
+ content:
+ `${sorted.map(([login]) => mention(login)).join(' ')} — reminder: ` +
+ `${total} issue${total === 1 ? '' : 's'} still open with no activity in ${staleDays}+ days` +
+ (totalBlocking > 0 ? `, together ${blockingPhrase(totalBlocking)}.` : '.'),
+ pingLogins: sorted.map(([login]) => login),
+ embed: {
+ title: 'Open work that has gone quiet',
+ url: `https://github.com/${repoFullName}/issues`,
+ color: COLORS.review,
+ description:
+ 'Everything here is assigned, unblocked, and workable — it just has not moved. ' +
+ 'Close it, comment with where it stands, or unassign yourself if it is not yours any more.\n\n' +
+ '⛔ marks how many other issues are waiting on that one.',
+ fields,
+ footer: {
+ text:
+ skippedBlocked > 0
+ ? `${skippedBlocked} blocked issue(s) left out — nothing to do on those yet.`
+ : 'Blocked and unassigned issues are left out.',
+ },
+ timestamp: new Date().toISOString(),
+ },
+})
diff --git a/.github/discord-bot/sprint.mjs b/.github/discord-bot/sprint.mjs
new file mode 100644
index 0000000..cfa1ab7
--- /dev/null
+++ b/.github/discord-bot/sprint.mjs
@@ -0,0 +1,238 @@
+/**
+ * Sprint reporting. Two modes, both driven by cron from a workflow.
+ *
+ * MODE=progress Wednesday and Saturday. Tells the whole team, with an @everyone,
+ * how far through the current sprint they are.
+ * MODE=overdue Every second day. Names everyone still holding an open issue in
+ * a sprint whose due date has passed, and escalates to @everyone
+ * when overdue work has nobody assigned at all — an unassigned
+ * late task is the team's problem, not one person's.
+ *
+ * A "sprint" is a GitHub milestone. Percentages come from the issues in it, not the
+ * milestone's own counters, which also count pull requests.
+ *
+ * progress speaks on schedule, because a status update people expect is not noise.
+ * overdue stays silent when nothing is late.
+ */
+import { assertConfig, everyoneMention, mention } from './config.mjs'
+import { COLORS, clamp, sendDiscord } from './discord.mjs'
+import { listMilestoneIssues, listMilestones } from './github.mjs'
+import {
+ dueState,
+ isOverdue,
+ percent,
+ pickCurrentSprint,
+ progressBar,
+ sortByRemaining,
+ tallyIssues,
+} from './sprints.mjs'
+
+assertConfig()
+
+const repoFullName = process.env.GITHUB_REPOSITORY
+if (!repoFullName) {
+ console.error('::error::GITHUB_REPOSITORY is not set.')
+ process.exit(1)
+}
+const [owner, repo] = repoFullName.split('/')
+
+const mode = (process.env.MODE || 'progress').toLowerCase()
+const now = Date.now()
+
+/** Up to `max` issue links, with an honest "+N more" rather than a silent cut. */
+function issueList(issues, max = 5) {
+ const shown = issues
+ .slice(0, max)
+ .map((i) => `[#${i.number}](${i.html_url}) ${String(i.title).slice(0, 44)}`)
+ if (issues.length > max) shown.push(`…and ${issues.length - max} more`)
+ return clamp(shown.join('\n'), 1000)
+}
+
+const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1)
+
+/** Wednesday / Saturday: where the current sprint stands, to the whole team. */
+async function reportProgress(milestones) {
+ const sprint = pickCurrentSprint(milestones, now)
+ const issues = await listMilestoneIssues(owner, repo, sprint.number, 'all')
+ const { pct, closed, open, total, byPerson, unassignedOpen } =
+ tallyIssues(issues)
+ const due = dueState(sprint, now)
+
+ console.log(
+ `[sprint] ${sprint.title}: ${closed}/${total} closed (${pct}%), ${due.text}`,
+ )
+
+ if (total === 0) {
+ console.log('[sprint] current sprint has no issues yet — staying quiet')
+ return
+ }
+
+ const everyone = everyoneMention()
+ const headline =
+ pct === 100
+ ? `**${sprint.title}** is done — all ${total} issues closed.`
+ : `**${sprint.title}** is **${pct}% complete** — ${closed}/${total} issues closed, ${open} still open.`
+
+ const fields = sortByRemaining(byPerson)
+ .slice(0, 24)
+ .map(([login, row]) => {
+ const owned = row.closed + row.open
+ return {
+ name: `@${login} — ${row.closed}/${owned} done (${percent(row.closed, owned)}%)`,
+ value: row.open === 0 ? '✅ nothing left' : issueList(row.openIssues),
+ }
+ })
+
+ if (unassignedOpen.length > 0) {
+ fields.push({
+ name: `Unassigned — ${unassignedOpen.length} open`,
+ value: `Nobody has picked these up yet:\n${issueList(unassignedOpen)}`,
+ })
+ }
+
+ await sendDiscord({
+ content: `${everyone.text} — sprint check-in: ${headline} ${capitalise(due.text)}.`,
+ everyone: true,
+ pingLogins: everyone.logins,
+ embed: {
+ title: `Sprint progress — ${sprint.title}`,
+ url: sprint.html_url,
+ color: due.overdue
+ ? COLORS.review
+ : pct === 100
+ ? COLORS.unblocked
+ : COLORS.ready,
+ description:
+ `\`${progressBar(pct)}\` **${pct}%**\n\n` +
+ `**${closed}** closed · **${open}** open · ${due.text}` +
+ (sprint.description ? `\n\n${clamp(sprint.description, 200)}` : ''),
+ fields,
+ footer: { text: 'Sprint check-in — every Wednesday and Saturday.' },
+ timestamp: new Date().toISOString(),
+ },
+ })
+}
+
+/** Every second day: who is late, and what nobody has claimed. */
+async function reportOverdue(milestones) {
+ const late = milestones.filter((m) => isOverdue(m, now))
+ if (late.length === 0) {
+ console.log('[sprint] no sprint is past its due date — staying quiet')
+ return
+ }
+
+ const openIssues = []
+ const finished = []
+ for (const milestone of late) {
+ const issues = await listMilestoneIssues(
+ owner,
+ repo,
+ milestone.number,
+ 'open',
+ )
+ // A late sprint with nothing open is finished; somebody just never closed it.
+ if (issues.length === 0) finished.push(milestone)
+ else openIssues.push(...issues)
+ }
+
+ const { byPerson, unassignedOpen, open } = tallyIssues(openIssues)
+ const people = sortByRemaining(byPerson)
+
+ console.log(
+ `[sprint] ${late.length} overdue sprint(s), ${open} open issue(s), ` +
+ `${people.length} person(s) behind, ${unassignedOpen.length} unassigned`,
+ )
+
+ if (open === 0) {
+ console.log(
+ '[sprint] overdue sprints have no open issues left — staying quiet',
+ )
+ return
+ }
+
+ const everyone = everyoneMention()
+ const needsEveryone = unassignedOpen.length > 0
+
+ const pings = [
+ ...(needsEveryone ? [everyone.text] : []),
+ ...people.map(([login]) => mention(login)),
+ ].join(' ')
+
+ const worst = late
+ .slice()
+ .sort((a, b) => new Date(a.due_on) - new Date(b.due_on))[0]
+ const worstDue = dueState(worst, now)
+
+ const fields = people
+ .slice(0, needsEveryone ? 24 : 25)
+ .map(([login, row]) => ({
+ name: `@${login} — ${row.open} still open`,
+ value: issueList(row.openIssues),
+ }))
+
+ if (needsEveryone) {
+ fields.push({
+ name: `⚠️ Unassigned — ${unassignedOpen.length} overdue with no owner`,
+ value: `These are late and nobody is on them. Someone has to take them:\n${issueList(unassignedOpen)}`,
+ })
+ }
+
+ await sendDiscord({
+ content:
+ `${pings} — ⏰ **${worst.title}** is ${worstDue.text}` +
+ (late.length > 1
+ ? ` (and ${late.length - 1} other sprint past due)`
+ : '') +
+ ` with **${open}** issue${open === 1 ? '' : 's'} still open.` +
+ (needsEveryone
+ ? ` ${unassignedOpen.length} of them ${unassignedOpen.length === 1 ? 'has' : 'have'} nobody assigned.`
+ : ''),
+ everyone: needsEveryone,
+ pingLogins: [
+ ...people.map(([login]) => login),
+ ...(needsEveryone ? everyone.logins : []),
+ ],
+ embed: {
+ title:
+ late.length === 1
+ ? `Overdue sprint — ${worst.title}`
+ : `${late.length} overdue sprints`,
+ url: worst.html_url,
+ color: COLORS.review,
+ description:
+ late
+ .map((m) => `• **${m.title}** — ${dueState(m, now).text}`)
+ .join('\n') +
+ '\n\nClose what is done, move what is not into the next sprint, or say where it stands. ' +
+ 'A sprint that quietly runs past its date stops meaning anything.',
+ fields,
+ footer: {
+ text: finished.length
+ ? `${finished.map((m) => m.title).join(', ')}: every issue closed — the milestone itself is still open.`
+ : 'Overdue check runs every second day.',
+ },
+ timestamp: new Date().toISOString(),
+ },
+ })
+}
+
+const milestones = await listMilestones(owner, repo, 'open')
+console.log(
+ `[sprint:${mode}] ${milestones.length} open milestone(s) in ${repoFullName}`,
+)
+
+if (milestones.length === 0) {
+ console.log('[sprint] no open milestones — nothing to report on')
+ process.exit(0)
+}
+
+if (mode === 'progress') {
+ await reportProgress(milestones)
+} else if (mode === 'overdue') {
+ await reportOverdue(milestones)
+} else {
+ console.error(
+ `::error::Unknown MODE "${mode}" — expected "progress" or "overdue".`,
+ )
+ process.exit(1)
+}
diff --git a/.github/discord-bot/sprints.mjs b/.github/discord-bot/sprints.mjs
new file mode 100644
index 0000000..bfd6c26
--- /dev/null
+++ b/.github/discord-bot/sprints.mjs
@@ -0,0 +1,135 @@
+/**
+ * Sprint maths, kept free of network and Discord so it can be unit-tested.
+ *
+ * A "sprint" is a GitHub milestone: it has a title, a due date, and a set of
+ * issues. Percentage complete is counted from the issues themselves rather than
+ * the milestone's own `open_issues`/`closed_issues` counters, because those
+ * counters also include pull requests filed against the milestone.
+ */
+
+const DAY = 86_400_000
+
+export function percent(closed, total) {
+ if (!total) return 0
+ return Math.round((closed / total) * 100)
+}
+
+/** `████████░░░░░░░░░░░░` — a bar that survives Discord's proportional font. */
+export function progressBar(pct, width = 20) {
+ const safe = Math.max(0, Math.min(100, Number(pct) || 0))
+ const filled = Math.round((safe / 100) * width)
+ return '█'.repeat(filled) + '░'.repeat(width - filled)
+}
+
+/**
+ * Where a milestone stands relative to its due date.
+ * A milestone with no due date is never overdue — there is nothing to be late for.
+ */
+export function dueState(milestone, now = Date.now()) {
+ if (!milestone?.due_on) {
+ return { dated: false, overdue: false, days: 0, text: 'no due date set' }
+ }
+
+ const remaining = new Date(milestone.due_on).getTime() - now
+
+ if (remaining >= 0) {
+ const days = Math.ceil(remaining / DAY)
+ return {
+ dated: true,
+ overdue: false,
+ days,
+ text: days <= 1 ? 'due within a day' : `due in ${days} days`,
+ }
+ }
+
+ const days = Math.floor(-remaining / DAY)
+ return {
+ dated: true,
+ overdue: true,
+ days,
+ text:
+ days === 0
+ ? 'overdue since earlier today'
+ : `overdue by ${days} day${days === 1 ? '' : 's'}`,
+ }
+}
+
+export const isOverdue = (milestone, now = Date.now()) =>
+ dueState(milestone, now).overdue
+
+/**
+ * The sprint the team is in right now.
+ *
+ * The nearest deadline that has not passed yet, because that is what everyone is
+ * working towards. If every dated sprint is already past due, the most recent one
+ * is still the current sprint — it is late, not finished. Undated milestones are a
+ * last resort, newest first, since a team that never sets due dates still wants a
+ * number on Wednesday.
+ */
+export function pickCurrentSprint(milestones, now = Date.now()) {
+ const open = (milestones ?? []).filter((m) => m && m.state !== 'closed')
+
+ const dated = open
+ .filter((m) => m.due_on)
+ .sort((a, b) => new Date(a.due_on) - new Date(b.due_on))
+
+ const upcoming = dated.find((m) => new Date(m.due_on).getTime() >= now)
+ if (upcoming) return upcoming
+ if (dated.length > 0) return dated[dated.length - 1]
+
+ const undated = open.slice().sort((a, b) => b.number - a.number)
+ return undated[0] ?? null
+}
+
+/**
+ * Break a set of issues down by state and by assignee.
+ *
+ * An issue with two assignees counts once for each of them, so the per-person
+ * numbers can add up to more than the sprint total. That is the honest reading:
+ * both people own it.
+ */
+export function tallyIssues(issues) {
+ let closed = 0
+ let open = 0
+ const byPerson = new Map()
+ const unassignedOpen = []
+
+ for (const issue of issues ?? []) {
+ const done = issue.state === 'closed'
+ if (done) closed++
+ else open++
+
+ const logins = (issue.assignees ?? []).map((a) => a.login).filter(Boolean)
+ if (logins.length === 0) {
+ if (!done) unassignedOpen.push(issue)
+ continue
+ }
+
+ for (const login of logins) {
+ const row = byPerson.get(login) ?? { closed: 0, open: 0, openIssues: [] }
+ if (done) row.closed++
+ else {
+ row.open++
+ row.openIssues.push(issue)
+ }
+ byPerson.set(login, row)
+ }
+ }
+
+ const total = closed + open
+ return {
+ closed,
+ open,
+ total,
+ pct: percent(closed, total),
+ byPerson,
+ unassignedOpen,
+ }
+}
+
+/** People with unfinished work first; ties broken by who has closed the least. */
+export function sortByRemaining(byPerson) {
+ return [...byPerson.entries()].sort(
+ (a, b) => b[1].open - a[1].open || a[1].closed - b[1].closed,
+ )
+}
diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml
new file mode 100644
index 0000000..72a1dec
--- /dev/null
+++ b/.github/workflows/discord-notify.yml
@@ -0,0 +1,49 @@
+name: Discord notifications
+
+on:
+ issues:
+ types: [assigned, closed, unlabeled, edited]
+ # pull_request_target (not pull_request) so notifications also work for PRs opened
+ # from forks, which are not given access to secrets. Safe here because the job only
+ # ever runs code from the base branch — it never checks out the PR's own code.
+ pull_request_target:
+ # ready_for_review pairs with opened: drafts are skipped when they appear and
+ # announced when the author says they are ready, so neither is missed or early.
+ types: [opened, ready_for_review, review_requested, closed]
+ pull_request_review:
+ types: [submitted]
+
+# No concurrency group on purpose. Grouping made GitHub cancel queued runs whenever
+# several events arrived together, and real notifications were dropped that way. An
+# occasional duplicate message is a much smaller problem than one that never arrives.
+
+permissions:
+ issues: read
+ pull-requests: read
+ contents: read
+
+jobs:
+ notify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ # Fetch only the bot itself, not the whole app — this repo is a .NET solution
+ # and everything else in it is dead weight for a notification.
+ - uses: actions/checkout@v4
+ with:
+ sparse-checkout: .github/discord-bot
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ persist-credentials: false
+
+ # No actions/setup-node: the runner image already ships a current Node, and the
+ # bot has no dependencies, so setting one up cost ~5s and bought nothing.
+ # No npm install for the same reason.
+ - name: Send Discord notification
+ run: node .github/discord-bot/notify.mjs
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DISCORD_REVIEW_WEBHOOK_URL: ${{ secrets.DISCORD_REVIEW_WEBHOOK_URL }}
+ DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BLOCKED_LABELS: blocked
diff --git a/.github/workflows/discord-reminders.yml b/.github/workflows/discord-reminders.yml
new file mode 100644
index 0000000..095693c
--- /dev/null
+++ b/.github/workflows/discord-reminders.yml
@@ -0,0 +1,52 @@
+name: Discord reminders
+
+on:
+ schedule:
+ # 13:00 UTC = 09:00 Montreal (EDT) / 08:00 (EST), every second day of the month.
+ # Carries two digests: work that has gone quiet, and sprints past their due date.
+ - cron: '0 13 */2 * *'
+ # Lets you run it on demand from the Actions tab without waiting for the schedule.
+ workflow_dispatch:
+ inputs:
+ stale_days:
+ description: 'Remind about issues with no activity for this many days'
+ required: false
+ default: '2'
+
+permissions:
+ issues: read
+ contents: read
+
+jobs:
+ remind:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ sparse-checkout: .github/discord-bot
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Send reminder digest
+ run: node .github/discord-bot/reminders.mjs
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ STALE_DAYS: ${{ inputs.stale_days || '2' }}
+ BLOCKED_LABELS: blocked
+
+ # Runs even if the digest above failed: the two are independent, and a late
+ # sprint is exactly the thing you do not want silently dropped.
+ - name: Chase overdue sprints
+ if: always()
+ run: node .github/discord-bot/sprint.mjs
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ MODE: overdue
+ # Only fires for overdue issues nobody has been assigned to.
+ ALLOW_EVERYONE: 'true'
diff --git a/.github/workflows/discord-sprint.yml b/.github/workflows/discord-sprint.yml
new file mode 100644
index 0000000..743a3fe
--- /dev/null
+++ b/.github/workflows/discord-sprint.yml
@@ -0,0 +1,41 @@
+name: Discord sprint progress
+
+on:
+ schedule:
+ # 13:00 UTC = 09:00 Montreal (EDT) / 08:00 (EST), Wednesday and Saturday.
+ # Mid-week to course-correct, weekend to see what the week actually landed.
+ - cron: '0 13 * * 3,6'
+ # Lets you post a check-in on demand from the Actions tab.
+ workflow_dispatch:
+ inputs:
+ mode:
+ description: 'progress (% complete) or overdue (who is late)'
+ type: choice
+ options: [progress, overdue]
+ default: progress
+
+permissions:
+ issues: read
+ contents: read
+
+jobs:
+ progress:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ sparse-checkout: .github/discord-bot
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Post sprint check-in
+ run: node .github/discord-bot/sprint.mjs
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ MODE: ${{ inputs.mode || 'progress' }}
+ # Set to false to name each teammate instead of firing a real @everyone.
+ ALLOW_EVERYONE: 'true'
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b962736
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,486 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
+
+# PRD
+PRD*.pdf
+PRD*.docx
\ No newline at end of file
diff --git a/MoissonMontreal.slnx b/MoissonMontreal.slnx
new file mode 100644
index 0000000..8a12e5a
--- /dev/null
+++ b/MoissonMontreal.slnx
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..b698a5c
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ db:
+ image: postgres:17
+ container_name: moisson-db
+ environment:
+ POSTGRES_USER: moisson
+ POSTGRES_PASSWORD: moisson
+ POSTGRES_DB: moisson_dev
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+volumes:
+ pgdata:
\ No newline at end of file
diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 0000000..39007e0
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.13",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..9dc0753
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "rollForward": "latestFeature",
+ "version": "10.0.100"
+ }
+}
diff --git a/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs b/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs
new file mode 100644
index 0000000..bf425b8
--- /dev/null
+++ b/src/MoissonMontreal.Api/Controllers/WeatherForecastController.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace MoissonMontreal.Api.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class WeatherForecastController : ControllerBase
+{
+ private static readonly string[] Summaries =
+ [
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+ ];
+
+ [HttpGet(Name = "GetWeatherForecast")]
+ public IEnumerable Get()
+ {
+ return Enumerable.Range(1, 5).Select(index => new WeatherForecast
+ {
+ Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = Summaries[Random.Shared.Next(Summaries.Length)]
+ })
+ .ToArray();
+ }
+}
diff --git a/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj b/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj
new file mode 100644
index 0000000..a3c98d8
--- /dev/null
+++ b/src/MoissonMontreal.Api/MoissonMontreal.Api.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/MoissonMontreal.Api/MoissonMontreal.Api.http b/src/MoissonMontreal.Api/MoissonMontreal.Api.http
new file mode 100644
index 0000000..01669a9
--- /dev/null
+++ b/src/MoissonMontreal.Api/MoissonMontreal.Api.http
@@ -0,0 +1,6 @@
+@MoissonMontreal.Api_HostAddress = http://localhost:5297
+
+GET {{MoissonMontreal.Api_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/src/MoissonMontreal.Api/Program.cs b/src/MoissonMontreal.Api/Program.cs
new file mode 100644
index 0000000..666a9c5
--- /dev/null
+++ b/src/MoissonMontreal.Api/Program.cs
@@ -0,0 +1,23 @@
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers();
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
diff --git a/src/MoissonMontreal.Api/Properties/launchSettings.json b/src/MoissonMontreal.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..82f9713
--- /dev/null
+++ b/src/MoissonMontreal.Api/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5297",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7173;http://localhost:5297",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/MoissonMontreal.Api/WeatherForecast.cs b/src/MoissonMontreal.Api/WeatherForecast.cs
new file mode 100644
index 0000000..be41ad1
--- /dev/null
+++ b/src/MoissonMontreal.Api/WeatherForecast.cs
@@ -0,0 +1,12 @@
+namespace MoissonMontreal.Api;
+
+public class WeatherForecast
+{
+ public DateOnly Date { get; set; }
+
+ public int TemperatureC { get; set; }
+
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+
+ public string? Summary { get; set; }
+}
diff --git a/src/MoissonMontreal.Api/appsettings.Development.json b/src/MoissonMontreal.Api/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/src/MoissonMontreal.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/MoissonMontreal.Api/appsettings.json b/src/MoissonMontreal.Api/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/src/MoissonMontreal.Api/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/MoissonMontreal.Core/Class1.cs b/src/MoissonMontreal.Core/Class1.cs
new file mode 100644
index 0000000..b83646d
--- /dev/null
+++ b/src/MoissonMontreal.Core/Class1.cs
@@ -0,0 +1,6 @@
+namespace MoissonMontreal.Core;
+
+public class Class1
+{
+
+}
diff --git a/src/MoissonMontreal.Core/MoissonMontreal.Core.csproj b/src/MoissonMontreal.Core/MoissonMontreal.Core.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/src/MoissonMontreal.Core/MoissonMontreal.Core.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/MoissonMontreal.Desktop/App.axaml b/src/MoissonMontreal.Desktop/App.axaml
new file mode 100644
index 0000000..f0cf256
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/App.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/MoissonMontreal.Desktop/App.axaml.cs b/src/MoissonMontreal.Desktop/App.axaml.cs
new file mode 100644
index 0000000..6dcf77a
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/App.axaml.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using MoissonMontreal.Desktop.ViewModels;
+using MoissonMontreal.Desktop.Views;
+
+namespace MoissonMontreal.Desktop;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/src/MoissonMontreal.Desktop/Assets/avalonia-logo.ico b/src/MoissonMontreal.Desktop/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/src/MoissonMontreal.Desktop/Assets/avalonia-logo.ico differ
diff --git a/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj b/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj
new file mode 100644
index 0000000..3ffe9ac
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/MoissonMontreal.Desktop.csproj
@@ -0,0 +1,29 @@
+
+
+ WinExe
+ net10.0
+ enable
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
diff --git a/src/MoissonMontreal.Desktop/Program.cs b/src/MoissonMontreal.Desktop/Program.cs
new file mode 100644
index 0000000..122194e
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/Program.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using System;
+
+namespace MoissonMontreal.Desktop;
+
+sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/MoissonMontreal.Desktop/ViewLocator.cs b/src/MoissonMontreal.Desktop/ViewLocator.cs
new file mode 100644
index 0000000..a7a5bce
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/ViewLocator.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using MoissonMontreal.Desktop.ViewModels;
+
+namespace MoissonMontreal.Desktop;
+
+///
+/// Given a view model, returns the corresponding view if possible.
+///
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs b/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..4c69889
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/ViewModels/MainViewModel.cs
@@ -0,0 +1,9 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace MoissonMontreal.Desktop.ViewModels;
+
+public partial class MainViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ public partial string Greeting { get; set; } = "Welcome to Avalonia!";
+}
diff --git a/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs b/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..c178d94
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace MoissonMontreal.Desktop.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/src/MoissonMontreal.Desktop/Views/MainWindow.axaml b/src/MoissonMontreal.Desktop/Views/MainWindow.axaml
new file mode 100644
index 0000000..a99a146
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/Views/MainWindow.axaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MoissonMontreal.Desktop/Views/MainWindow.axaml.cs b/src/MoissonMontreal.Desktop/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..3020590
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/Views/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace MoissonMontreal.Desktop.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/MoissonMontreal.Desktop/app.manifest b/src/MoissonMontreal.Desktop/app.manifest
new file mode 100644
index 0000000..a2aa368
--- /dev/null
+++ b/src/MoissonMontreal.Desktop/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj b/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj
new file mode 100644
index 0000000..bcb3521
--- /dev/null
+++ b/tests/MoissonMontreal.Tests/MoissonMontreal.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/MoissonMontreal.Tests/UnitTest1.cs b/tests/MoissonMontreal.Tests/UnitTest1.cs
new file mode 100644
index 0000000..388bf92
--- /dev/null
+++ b/tests/MoissonMontreal.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace MoissonMontreal.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}