feat(addons): GitHub settings page, registration and docs

Add the GitHub connection page (fine-grained token, repository list,
read-only note, Test that checks each listed repository), let the shared
connector page hide messaging settings for a kind that carries no
messages, register the add-on in the package matrix and the catalog, and
document the credential slot and the GitHub add-on.

Assisted-by: Claude:claude-sonnet-5-5 [go node]
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:20:22 +00:00
1 parent 3d687a209c
commit 3bbb40b6ff
19 files changed
+557 -24

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { RouteProvider } from 'react-router-dom'
import { getSdk, } from '../../../internal/host-compat/src/runtime.js'
import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
import ConnectorPage from '../../../internal/connector-ui/src/ConnectorPage'
import { Field, FieldError, Notice, TextInput } from '../../../internal/connector-ui/src/controls'
import en from './i18n/github.en.json'
import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem } from './repos'
registerLocales({ github: { en } })
// The fine-grained token. It is typed only to replace it.
function GitHubToken({ form, setForm, t }) {
const problem = tokenProblem(form)
return (
<Field label={t('token')} hint={t('tokenHelp')}>
{(p) => (
<TextInput {...p} mono type="password" autoComplete="off" value={form.token || ''} required={!form.id}
placeholder={form.id ? t('tokenSaved') : ''} aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, token: e.target.value })} />
)}
</Field>
)
}
// The repositories the agent may read. The host refuses any other one before
// the add-on is called.
function GitHubFields({ form, setForm, t }) {
const problem = reposProblem(form.repos_text)
const count = parseRepos(form.repos_text).length
return (
<>
{tokenProblem(form) && <FieldError role="status" data-testid="token-problem">{t(tokenProblem(form))}</FieldError>}
<Field label={t('repos')} hint={t('reposHelp', { count })}>
{(p) => (
<textarea {...p} className="cn-input cn-input--mono" rows={5} data-testid="github-repos" value={form.repos_text || ''}
placeholder="owner/name" aria-invalid={problem ? 'true' : undefined}
onChange={(e) => setForm({ ...form, repos_text: e.target.value })} />
)}
</Field>
{problem && <FieldError role="status" data-testid="repos-problem">{t(problem)}</FieldError>}
<Notice tone="info" role="note" testId="github-write-mode">{t('writeOff')}</Notice>
</>
)
}
function Help({ t }) {
return (
<details className="cn-help" data-testid="github-setup">
<summary>{t('setup.title')}</summary>
<ol>
<li>{t('setup.step1')}</li>
<li>{t('setup.step2')}</li>
<li>{t('setup.step3')}</li>
</ol>
</details>
)
}
function RowInfo({ c, t }) {
const repos = c.resources || []
return <span className="cn-muted" data-testid="github-row-repos">{repos.length === 0 ? t('noRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}</span>
}
// The Test button: the host asks who the token is; this asks, for each listed
// repository, whether the token reaches it.
async function testExtra(c, t) {
const lines = []
for (const repo of c.resources || []) {
try {
const r = await getSdk().api.read({ arguments: { tool: 'check', repo } })
lines.push(checkLine(r?.data ? { ...r.data, repo } : { repo, reachable: false }, t))
} catch (_e) {
lines.push(checkLine({ repo, reachable: false }, t))
}
}
return lines.join(' ')
}
export function createGitHub() {
function Component({ page, path, search }) {
if (page !== 'apps') return null
return (
<RouteProvider value={{ page, path: path || '', search: search || '' }}>
<ConnectorPage kind="github" ns="github" testId="github-page" TokenFields={GitHubToken} tokenOf={githubSecret} messaging={false}
ExtraFields={GitHubFields} extraBody={extraBody} extraForm={extraForm} extraValid={extraValid} testExtra={testExtra}
Help={Help} RowInfo={RowInfo} />
</RouteProvider>
)
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
+49
View File
@@ -0,0 +1,49 @@
{
"title": "GitHub",
"intro": "Let your agents read the code, issues and pull requests of the repositories you list. The agent can read only those, with the token you save here. Nothing is written to GitHub.",
"new": "New GitHub connection",
"edit": "Edit GitHub connection",
"name": "Name",
"token": "Fine-grained access token",
"tokenHelp": "Starts with github_pat_. Give it read access to the repositories below only: Contents, Issues and Pull requests. It is kept sealed and never shown again.",
"tokenSaved": "Saved. Type a token to replace it.",
"tokenShape": "Use a fine-grained token: it starts with github_pat_.",
"tokenSpace": "The token has no spaces.",
"repos": "Repositories the agent may read",
"reposHelp": "One per line, as owner/name. {{count}} listed. The agent is refused any other repository, even if the token could reach it.",
"reposShape": "Each repository is written owner/name.",
"reposMany": "At most 50 repositories.",
"writeOff": "Write access is off: this connection only reads.",
"agent": "Agent",
"agentNone": "Choose an agent",
"agentNoModel": "{{name}} (needs a model)",
"test": "Test",
"testOk": "Token of {{bot}}.",
"checkFailed": "Saved. The token check failed: {{reason}}",
"check": {
"unreachable": "{{repo}}: not reachable with this token.",
"readOnly": "{{repo}}: reachable, read only.",
"readWrite": "{{repo}}: reachable. The token could also write, so give it read access only."
},
"save": "Save",
"cancel": "Cancel",
"editButton": "Edit",
"delete": "Delete",
"empty": "No GitHub connections yet.",
"noRepos": "No repositories listed: the agent can read none.",
"reposOf": "{{count}} repositories: {{list}}",
"agentOf": "Agent: {{name}}",
"inactive": "Not running: {{reason}}.",
"confirmDelete": {
"title": "Delete {{name}}?",
"message": "The agent can no longer read these repositories, and the token saved with it is deleted. To set it up again you must enter the token again.",
"confirm": "Delete"
},
"setup": {
"title": "How to make the token",
"step1": "On GitHub, open Settings, Developer settings, Fine-grained personal access tokens, and make a new token.",
"step2": "Under Repository access, choose Only select repositories and pick the ones you list here.",
"step3": "Under Permissions, give Read access to Contents, Issues and Pull requests. Leave everything else off."
},
"back": "Back to connectors"
}
+14
View File
@@ -0,0 +1,14 @@
// The entry of the GitHub add-on. Its page edits the bindings of GitHub
// tokens through the core API, with the signed-in person's own session. It
// uses the host's React, router and i18n through the stand-ins of
// addons/internal/host-compat, like the Slack add-on.
import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
const NEEDS = ['ui.ConfirmDialog']
export async function createAddon(sdk) {
requireHost(sdk, NEEDS)
setSdk(sdk)
const { createGitHub } = await import('./addon.jsx')
return createGitHub(sdk)
}
+57
View File
@@ -0,0 +1,57 @@
// Helpers of the GitHub page. No React, so a node test drives them.
// A fine-grained token. A classic token (ghp_) reaches every repository of
// the account, so the page does not take it.
export const TOKEN_PREFIX = 'github_pat_'
// What is wrong with the typed token, as a key of the page's words, or ''.
// Nothing typed is no problem: an update without a token keeps the stored one.
export function tokenProblem(form) {
const token = (form.token || '').trim()
if (!token) return ''
if (/\s/.test(token)) return 'tokenSpace'
if (!token.startsWith(TOKEN_PREFIX)) return 'tokenShape'
return ''
}
// The secret the field makes: the token, or '' when not typed or not right.
export function githubSecret(form) {
return tokenProblem(form) ? '' : (form.token || '').trim()
}
export const MAX_REPOS = 50
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
// The repositories of a text box: one per line, or separated by commas or
// spaces. Blank entries and repeats (any case) are dropped.
export function parseRepos(text) {
const seen = new Set()
const out = []
for (const part of String(text || '').split(/[\s,]+/)) {
const r = part.trim()
if (!r || seen.has(r.toLowerCase())) continue
seen.add(r.toLowerCase())
out.push(r)
}
return out
}
// What is wrong with the typed list, as a key of the page's words, or ''.
export function reposProblem(text) {
const list = parseRepos(text)
if (list.length > MAX_REPOS) return 'reposMany'
if (list.some((r) => !REPO.test(r) || r.includes('..'))) return 'reposShape'
return ''
}
// The fields this page adds to a binding: its form values, the request body
// they make, and whether they allow saving.
export const extraForm = (c) => ({ repos_text: (c.resources || []).join('\n') })
export const extraBody = (form) => ({ resources: parseRepos(form.repos_text) })
export const extraValid = (form) => !reposProblem(form.repos_text)
// The line the Test button adds for each repository, from the check tool.
export function checkLine(r, t) {
if (!r || r.reachable === false) return t('check.unreachable', { repo: r?.repo || '' })
return t(r.canWrite ? 'check.readWrite' : 'check.readOnly', { repo: r.fullName || r.repo })
}