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() + { + + } +}