diff --git a/api/lib/local_ipc.ts b/api/lib/local_ipc.ts index 45e6ab3..bdfda62 100644 --- a/api/lib/local_ipc.ts +++ b/api/lib/local_ipc.ts @@ -74,6 +74,8 @@ const commands: Record< teamId: 'local', isPublic: true, repositoryUrl: null, + discordChannelId: null, + jiraProjectKey: null, }) } diff --git a/api/routes.ts b/api/routes.ts index 5262035..c408874 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -188,6 +188,8 @@ const projectOutput = OBJ({ teamId: STR('The ID of the team that owns the project'), isPublic: BOOL('Is the project public?'), repositoryUrl: optional(STR('The URL of the project repository')), + jiraProjectKey: optional(STR('The Jira project key, e.g. "TNT"')), + discordChannelId: optional(STR('The ID of the project Discord channel')), createdAt: optional(NUM('The creation date of the project')), updatedAt: optional(NUM('The last update date of the project')), }) @@ -349,6 +351,10 @@ const defs = { teamId: STR('The ID of the team that owns the project'), isPublic: BOOL('Is the project public?'), repositoryUrl: optional(STR('The URL of the project repository')), + jiraProjectKey: optional(STR('The Jira project key, e.g. "TNT"')), + discordChannelId: optional( + STR('The ID of the project Discord channel'), + ), }, 'Create a new project'), output: projectOutput, description: 'Create a new project', @@ -375,6 +381,10 @@ const defs = { teamId: STR('The ID of the team that owns the project'), isPublic: BOOL('Is the project public?'), repositoryUrl: optional(STR('The URL of the project repository')), + jiraProjectKey: optional(STR('The Jira project key, e.g. "TNT"')), + discordChannelId: optional( + STR('The ID of the project Discord channel'), + ), }), output: projectOutput, description: 'Update a project by ID', diff --git a/api/schema.ts b/api/schema.ts index 4ccd606..f575b94 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -50,6 +50,8 @@ export const ProjectDef = OBJ({ teamId: STR('The ID of the team that owns the project'), isPublic: BOOL('Is the project public?'), repositoryUrl: optional(STR('The URL of the project repository')), + jiraProjectKey: optional(STR('The Jira project key, e.g. "TNT"')), + discordChannelId: optional(STR('The ID of the project Discord channel')), }, 'The project schema definition') export type Project = Asserted diff --git a/api/team-directory.ts b/api/team-directory.ts new file mode 100644 index 0000000..2b1b0ad --- /dev/null +++ b/api/team-directory.ts @@ -0,0 +1,101 @@ +import { get } from './lmdb-store.ts' + +// Matches the `Person` shape from tickets.ts (issue 1, a separate branch as +// of this issue) — duplicated rather than imported since this issue has no +// dependency on it. +export type Person = { + id: string + name: string + emails: string[] + githubLogin?: string + discordId?: string + jiraAccountId?: string +} + +type GoogleUser = { + id: string + primaryEmail: string + emails: string[] + name: string +} + +type JiraUser = { + accountId: string + email: string | null +} + +// `name`/`global_name` come back as `[]`, not `null`, when unset on some +// store records (an upstream sync quirk) — typed loosely and guarded with +// `typeof` rather than trusted. +type GithubUser = { + login: string + name: unknown +} + +type DiscordUser = { + id: string + global_name: unknown +} + +const GOOGLE_USER_QUERY = + '{id: .id, primaryEmail: .primaryEmail, emails: [.emails[]?.address], name: .name.fullName}' +const JIRA_USER_QUERY = '{accountId: .accountId, email: .emailAddress}' +const GITHUB_USER_QUERY = '{login: .login, name: .name}' +const DISCORD_USER_QUERY = '{id: .id, global_name: .global_name}' + +// github/user and discord/user carry no email to join against google/user +// by, so this falls back to an exact, case-insensitive full-name match — +// the only signal shared with google/user's `name.fullName`. A name shared +// by more than one account is dropped rather than guessed at (e.g. two +// real github accounts both just named "Henri"). +const uniqueByName = ( + items: T[], + getName: (item: T) => unknown, +): Map => { + const byName = new Map() + const ambiguous = new Set() + for (const item of items) { + const name = getName(item) + if (typeof name !== 'string' || !name) continue + const key = name.toLowerCase() + if (byName.has(key)) ambiguous.add(key) + else byName.set(key, item) + } + for (const key of ambiguous) byName.delete(key) + return byName +} + +export const mergeTeamDirectory = ( + googleUsers: GoogleUser[], + jiraUsers: JiraUser[], + githubUsers: GithubUser[], + discordUsers: DiscordUser[], +): Person[] => { + const githubByName = uniqueByName(githubUsers, (u) => u.name) + const discordByName = uniqueByName(discordUsers, (u) => u.global_name) + + return googleUsers.map((user) => { + const emails = [...new Set([user.primaryEmail, ...user.emails])] + const jiraUser = jiraUsers.find((j) => j.email && emails.includes(j.email)) + const nameKey = user.name.toLowerCase() + return { + id: user.id, + name: user.name, + emails, + jiraAccountId: jiraUser?.accountId, + githubLogin: githubByName.get(nameKey)?.login, + discordId: discordByName.get(nameKey)?.id, + } + }) +} + +export const buildTeamDirectory = async (): Promise => { + const [googleUsers, jiraUsers, githubUsers, discordUsers] = await Promise + .all([ + get('google/user', { q: GOOGLE_USER_QUERY }), + get('jira/user', { q: JIRA_USER_QUERY }), + get('github/user', { q: GITHUB_USER_QUERY }), + get('discord/user', { q: DISCORD_USER_QUERY }), + ]) + return mergeTeamDirectory(googleUsers, jiraUsers, githubUsers, discordUsers) +} diff --git a/tasks/seed.ts b/tasks/seed.ts index d82c845..f62f7e5 100644 --- a/tasks/seed.ts +++ b/tasks/seed.ts @@ -12,6 +12,8 @@ const projects: Omit[] = [ teamId: 'frontend-devs', isPublic: true, repositoryUrl: 'https://github.com/example/website', + discordChannelId: null, + jiraProjectKey: null, }, { slug: 'api-refactor', @@ -19,6 +21,8 @@ const projects: Omit[] = [ teamId: 'backend-devs', isPublic: false, repositoryUrl: 'https://github.com/example/api', + discordChannelId: null, + jiraProjectKey: null, }, { slug: 'design-system', @@ -26,6 +30,8 @@ const projects: Omit[] = [ teamId: 'frontend-devs', isPublic: true, repositoryUrl: 'https://github.com/example/design-system', + discordChannelId: null, + jiraProjectKey: null, }, ] diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx index 12312b0..278b537 100644 --- a/web/pages/ProjectsPage.tsx +++ b/web/pages/ProjectsPage.tsx @@ -63,6 +63,8 @@ async function saveProject( teamId, repositoryUrl, isPublic: isPublic ?? false, + discordChannelId: null, + jiraProjectKey: null, }) projects.fetch() navigate({ params: { dialog: null, slug: null }, replace: true })