+
{() => (
setForm({ ...form, write_mode: m })}
- options={WRITE_MODES.map((m) => ({ value: m, label: t(`writeMode.${m}`) }))} />
+ options={WRITE_MODES.map((m) => ({
+ value: m,
+ label: ({t(`writeMode.${m}`)}{t(`writeMode.line.${m}`)}),
+ }))} />
)}
- {t(`writeMode.note.${mode}`)}
+ {t(`writeMode.note.${mode}`)}
)
}
+// What the page does not do, always shown; and how to make the token, open
+// while there is no connection yet.
function Help({ t }) {
+ const [open, setOpen] = useState(null)
+ useEffect(() => {
+ let live = true
+ connectorsApi.list().then((r) => { if (live && open === null) setOpen(!(r.connectors || []).some((c) => c.kind === 'github')) }).catch(() => {})
+ return () => { live = false }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once; the person's own toggle wins afterwards
+ }, [])
+ const level = (k) => t(`setup.table.levels.${k}`)
return (
-
- {t('setup.title')}
-
- - {t('setup.step1')}
- - {t('setup.step2')}
- - {t('setup.step3')}
-
-
+
+
{t('limits')}
+
setOpen(e.currentTarget.open)}>
+ {t('setup.title')}
+
+
+ - {t('setup.step1')}
+ - {t('setup.step2')}
+ - {t('setup.step3')}
+ - {t('setup.step4')}
+
+
+ {t('setup.table.caption')}
+
+ | {t('setup.table.permission')} | {t('setup.table.read')} | {t('setup.table.write')} |
+
+
+ {PERMISSIONS.map((p) => (
+ | {t(`setup.table.${p.id}`)} | {level(p.read)} | {level(p.write)} |
+ ))}
+
+
+
+
+ )
+}
+
+function useResult(id) {
+ const [, tick] = useState(0)
+ useEffect(() => subscribe(() => tick((n) => n + 1)), [])
+ return resultOf(id)
+}
+
+// The Test result of one connection: a line for each repository, drawn in
+// its row, with a tone.
+function Checks({ c, t }) {
+ const r = useResult(c.id)
+ if (!r) return null
+ if (r.busy) return
{t('check.running')}
+ return (
+
+
+ {r.items.map((i) => (
+ -
+
+ {i.text}
+
+ ))}
+
+ {r.at > 0 &&
{t('check.at', { time: new Date(r.at).toLocaleTimeString() })}
}
+
)
}
function RowInfo({ c, t }) {
const repos = c.resources || []
+ const shown = Boolean(resultOf(c.id)) && repos.length > 0
+ const mode = writeModeOf(c.write_mode)
return (
<>
-
{repos.length === 0 ? t('noRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}
-
{t(`writeMode.row.${writeModeOf(c.write_mode)}`)}
+ {!shown &&
{repos.length === 0 ? t('noRepos') : t('reposOf', { count: repos.length, list: repos.join(', ') })}}
+
{t(`writeMode.row.${mode}`)}
+
>
)
}
// The Test button: the host asks who the token is; this asks, for each listed
-// repository, whether the token reaches it.
+// repository, whether the token reaches it. The result goes to the row, so the
+// text it returns for the top notice is empty.
async function testExtra(c, t) {
- const lines = []
+ startTest(c.id)
+ const items = []
for (const repo of c.resources || []) {
+ let r
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))
+ const res = await getSdk().api.read({ arguments: { tool: 'check', repo } })
+ r = res?.data ? { ...res.data, repo } : { repo, reachable: false }
+ } catch (e) {
+ r = { repo, failed: checkFailure(e) }
}
+ const i = checkItem(r)
+ items.push({ repo, tone: i.tone, text: t(i.key, i.vars) })
}
- return lines.join(' ')
+ finishTest(c.id, items)
+ return ''
}
export function createGitHub() {
diff --git a/addons/github/ui/src/github.css b/addons/github/ui/src/github.css
new file mode 100644
index 0000000000..7c621bead8
--- /dev/null
+++ b/addons/github/ui/src/github.css
@@ -0,0 +1,41 @@
+/* Styles of the GitHub add-on page and its approval card. Tokens only. */
+.gh-help { display: flex; flex-direction: column; gap: var(--dk-space-2); }
+.gh-help .cn-help { max-width: 640px; }
+.gh-help details[open] { padding: var(--dk-space-3); border: 1px solid var(--color-border-subtle); border-radius: var(--r-md); }
+.gh-help details > p { margin: var(--dk-space-2) 0; }
+.gh-link { color: var(--dk-accent-text); text-underline-offset: 3px; }
+.gh-perms { width: 100%; border-collapse: collapse; font-size: var(--fs-small); margin-top: var(--dk-space-2); }
+.gh-perms caption { text-align: left; color: var(--color-text-muted); padding-bottom: var(--dk-space-1); }
+.gh-perms th, .gh-perms td { text-align: left; padding: var(--dk-space-1) var(--dk-space-2); border-bottom: 1px solid var(--color-border-subtle); vertical-align: top; }
+.gh-perms thead th { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); }
+.gh-perms tbody th { font-weight: var(--font-weight-semibold); }
+
+.gh-mode-block { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.gh-modecard { display: flex; flex-direction: column; gap: 2px; }
+.gh-modecard__title { font-weight: var(--font-weight-semibold); }
+.gh-modecard__line { color: var(--color-text-muted); font-size: var(--fs-small); }
+.gh-note { margin-top: var(--dk-space-1); }
+
+.gh-chip { display: inline-block; font-size: var(--fs-meta); padding: 0 var(--dk-space-2); border-radius: var(--r-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
+.gh-chip--off { color: var(--dk-ok-text, var(--color-text-secondary)); background: var(--dk-ok-wash, var(--color-bg-secondary)); }
+.gh-chip--propose { color: var(--dk-warn-text, var(--color-text-secondary)); background: var(--dk-warn-wash, var(--color-bg-secondary)); }
+.gh-chip--auto { color: var(--dk-error-text, var(--color-text-secondary)); background: var(--dk-error-wash, var(--color-bg-secondary)); }
+
+.gh-checks { list-style: none; margin: var(--dk-space-1) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-1); }
+.gh-check { display: flex; align-items: flex-start; gap: var(--dk-space-2); font-size: var(--fs-small); line-height: var(--lh-small); overflow-wrap: anywhere; }
+.gh-check--ok { color: var(--dk-ok-text, var(--color-text-secondary)); }
+.gh-check--warn { color: var(--dk-warn-text, var(--color-text-secondary)); }
+
+.gh-prop { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.gh-prop__title { margin: 0; font-size: var(--fs-body); font-weight: var(--font-weight-semibold); }
+.gh-prop__facts { margin: 0; display: flex; flex-direction: column; gap: var(--dk-space-2); }
+.gh-prop__row { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: var(--dk-space-2); }
+.gh-prop__row dt { color: var(--color-text-muted); font-size: var(--fs-small); }
+.gh-prop__row dd { margin: 0; overflow-wrap: anywhere; }
+.gh-prop__target { display: block; color: var(--color-text-muted); font-size: var(--fs-small); }
+.gh-prop__label { margin: 0; color: var(--color-text-muted); font-size: var(--fs-small); }
+.gh-prop__text {
+ margin: 0; padding: var(--dk-space-3); border-left: 3px solid var(--color-border-subtle); border-radius: var(--r-sm);
+ background: var(--color-bg-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto;
+}
+@media (max-width: 480px) { .gh-prop__row { grid-template-columns: minmax(0, 1fr); gap: 0; } }
diff --git a/addons/github/ui/src/i18n/github.en.json b/addons/github/ui/src/i18n/github.en.json
index 87eb7ed261..c2b2414b82 100644
--- a/addons/github/ui/src/i18n/github.en.json
+++ b/addons/github/ui/src/i18n/github.en.json
@@ -1,17 +1,19 @@
{
"title": "GitHub",
- "intro": "Let your agents read the code, issues and pull requests of the repositories you list, and, if you allow it, comment and open issues there. The agent reaches only those repositories, with the token you save here.",
+ "intro": "Let your agents read the code, issues and pull requests of the repositories you list. If you allow it, they can also comment and open issues there.",
+ "limits": "This connection does not answer @mentions on GitHub yet. An agent works on GitHub only when you ask it in a chat.",
"new": "New GitHub connection",
"edit": "Edit GitHub connection",
"name": "Name",
"token": "Fine-grained access token",
- "tokenHelp": "Starts with github_pat_. Give it access to the repositories below only. Reading needs Contents, Issues and Pull requests (read). Writing also needs Issues and Pull requests (write). It is kept sealed and never shown again.",
+ "tokenHelp": "Starts with github_pat_. It is kept sealed and never shown again.",
+ "tokenLink": "Create a token on GitHub",
"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",
+ "tokenSpace": "The token has no spaces. Copy it again without the spaces around it.",
+ "repos": "Repositories the agent may use",
"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.",
+ "reposShape": "\"{{entry}}\" is not written owner/name, for example mudler/LocalAI.",
"reposMany": "At most 50 repositories.",
"agent": "Agent",
"agentNone": "Choose an agent",
@@ -20,12 +22,17 @@
"credentialsOnly": "Credentials only",
"agentNoModel": "{{name}} (needs a model)",
"test": "Test",
- "testOk": "Token of {{bot}}.",
+ "testOk": "Connected as {{bot}}.",
"checkFailed": "Saved. The token check failed: {{reason}}",
"check": {
- "unreachable": "{{repo}}: not reachable with this token.",
+ "title": "Check of {{name}}",
+ "running": "Checking the repositories...",
+ "at": "Checked at {{time}}",
+ "unreachable": "{{repo}}: this token cannot reach it. Add it to the token's repositories on GitHub.",
"readOnly": "{{repo}}: reachable, read only.",
- "readWrite": "{{repo}}: reachable, and the token can write."
+ "readWrite": "{{repo}}: reachable, and the token can write.",
+ "refused": "{{repo}}: GitHub refused this token. Check that it has not expired and that it covers this repository.",
+ "failed": "{{repo}}: the check did not finish. Try again in a moment."
},
"save": "Save",
"cancel": "Cancel",
@@ -43,26 +50,81 @@
},
"setup": {
"title": "How to make the token",
- "step1": "On GitHub, open Settings, Developer settings, Fine-grained personal access tokens, and make a new token.",
+ "link": "Create a token on GitHub",
+ "linkLabel": "Create a token on GitHub (opens in a new tab)",
+ "step1": "Open GitHub with the button above. Name the token, for example LocalAI, and set an expiry. Ninety days is a good start.",
"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. To let the agent write, give Read and write to Issues and Pull requests. Leave everything else off."
+ "step3": "Under Permissions, set what the table says for the mode you want. Leave everything else off.",
+ "step4": "Create the token, copy it and paste it in the token field. GitHub shows it only once.",
+ "table": {
+ "caption": "Repository permissions to give the token",
+ "permission": "Permission",
+ "read": "Read only",
+ "write": "Asks first, or acts without asking",
+ "contents": "Contents",
+ "issues": "Issues",
+ "pulls": "Pull requests",
+ "metadata": "Metadata",
+ "levels": {
+ "read": "Read",
+ "readwrite": "Read and write",
+ "auto": "Read (GitHub sets it)"
+ }
+ }
},
- "back": "Back to connectors",
"writeMode": {
"label": "What the agent may change on GitHub",
- "hint": "Applies to comments, new issues, labels, closing issues and line comments on pull requests. The agent cannot merge, delete or push code.",
- "off": "Nothing (read only)",
- "propose": "Ask me each time",
- "auto": "Allowed",
+ "hint": "Comments, new issues, labels and closing issues. It never merges, deletes or pushes code.",
+ "off": "Read only",
+ "propose": "Asks you first",
+ "auto": "Acts without asking",
+ "line": {
+ "off": "It cannot change anything on GitHub.",
+ "propose": "Each change waits for your OK.",
+ "auto": "Your chats post at once."
+ },
"note": {
"off": "Write access is off: this connection only reads.",
"propose": "Each change waits in your approvals until you confirm it. Anything an agent writes is public to everyone who can see the repository.",
"auto": "A chat of yours posts the change at once, without asking. Anything the agent writes is public to everyone who can see the repository. Runs without you present still wait for your confirmation."
},
"row": {
- "off": "Read only.",
- "propose": "Changes wait for your confirmation.",
- "auto": "Changes are allowed without asking."
+ "off": "Read only",
+ "propose": "Asks you first",
+ "auto": "Acts without asking"
}
+ },
+ "approval": {
+ "action": {
+ "comment-issue": "Comment on issue #{{number}}",
+ "comment-pr": "Comment on pull request #{{number}}",
+ "create-issue": "Open a new issue",
+ "edit-issue": "Change #{{number}}",
+ "add-labels": "Add labels to #{{number}}",
+ "review-comment-pr": "Comment on a line of pull request #{{number}}"
+ },
+ "where": "Where",
+ "repo": "In {{repo}}",
+ "open": "Open #{{number}} on GitHub",
+ "as": "As",
+ "asOne": "The connection {{name}}",
+ "asFirst": "The first connection that lists {{repo}}",
+ "asUnknown": "A GitHub connection that lists {{repo}}",
+ "text": "Text that will be posted",
+ "title": "Title",
+ "newTitle": "New title",
+ "labels": "Labels",
+ "file": "File and line",
+ "fileLine": "{{path}}, line {{line}} ({{side}})",
+ "sideRight": "new file",
+ "sideLeft": "old file",
+ "state": "Change",
+ "close": "Close it",
+ "closeReason": "Close it, as {{reason}}",
+ "reopen": "Reopen it",
+ "completed": "completed",
+ "not_planned": "not planned",
+ "public": "Public: everyone who can see {{repo}} will see this.",
+ "cut": "The text shown here is cut. The full text, {{bytes}} bytes, is posted."
}
}
diff --git a/addons/github/ui/src/repos.js b/addons/github/ui/src/repos.js
index 2a65efde53..d291ddfe41 100644
--- a/addons/github/ui/src/repos.js
+++ b/addons/github/ui/src/repos.js
@@ -22,12 +22,13 @@ export function githubSecret(form) {
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.
+// The repositories of a text box: one per line, or separated by commas. A
+// space is not a separator, so "not a repo" is one entry and is named when it
+// is wrong. 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,]+/)) {
+ for (const part of String(text || '').split(/[\n\r,]+/)) {
const r = part.trim()
if (!r || seen.has(r.toLowerCase())) continue
seen.add(r.toLowerCase())
@@ -36,12 +37,33 @@ export function parseRepos(text) {
return out
}
-// What is wrong with the typed list, as a key of the page's words, or ''.
-export function reposProblem(text) {
+const validRepo = (r) => REPO.test(r) && !r.includes('..')
+
+// The first thing wrong with the typed list: { key, entry } where key is a key
+// of the page's words and entry is the line to name, or null.
+export function reposIssue(text) {
const list = parseRepos(text)
- if (list.length > MAX_REPOS) return 'reposMany'
- if (list.some((r) => !REPO.test(r) || r.includes('..'))) return 'reposShape'
- return ''
+ if (list.length > MAX_REPOS) return { key: 'reposMany', entry: '' }
+ const bad = list.find((r) => !validRepo(r))
+ return bad === undefined ? null : { key: 'reposShape', entry: bad.length > 60 ? `${bad.slice(0, 57)}...` : bad }
+}
+
+// The same, as a key only, or ''.
+export const reposProblem = (text) => reposIssue(text)?.key || ''
+
+// The name a new connection gets from its first valid repository.
+export function defaultName(text) {
+ const first = parseRepos(text).find(validRepo)
+ return first ? `GitHub: ${first}` : ''
+}
+
+// The name after the repositories changed: an empty name, or one that this
+// page made itself from the earlier text, follows the new first repository. A
+// name the person typed stays.
+export function followName(name, oldText, newText) {
+ const current = String(name || '')
+ if (current !== '' && current !== defaultName(oldText)) return current
+ return defaultName(newText)
}
// What the agent may change on GitHub with this connection. The host keeps it
@@ -58,8 +80,32 @@ export const extraForm = (c) => ({ repos_text: (c.resources || []).join('\n'), w
export const extraBody = (form) => ({ resources: parseRepos(form.repos_text), write_mode: writeModeOf(form.write_mode) })
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 })
+// What the Test button shows for one repository: { tone, key, vars }, where
+// key is a key of the page's words under check. A failure of the check itself
+// is r.failed: 'refused' when GitHub turned the token down, else 'other'.
+export function checkItem(r) {
+ const repo = r?.repo || ''
+ if (r?.failed === 'refused') return { tone: 'warn', key: 'check.refused', vars: { repo } }
+ if (r?.failed) return { tone: 'warn', key: 'check.failed', vars: { repo } }
+ if (!r || r.reachable === false) return { tone: 'warn', key: 'check.unreachable', vars: { repo } }
+ return { tone: 'ok', key: r.canWrite ? 'check.readWrite' : 'check.readOnly', vars: { repo: r.fullName || repo } }
}
+
+// The sentence of one repository, for a test or a plain-text summary.
+export const checkLine = (r, t) => { const i = checkItem(r); return t(i.key, i.vars) }
+
+// Why a call of the check tool failed, as the failed field of checkItem. A 401
+// or 403, or the words GitHub uses for a token it turned down, is a refusal.
+export function checkFailure(e) {
+ const status = Number(e?.status || e?.code)
+ if (status === 401 || status === 403) return 'refused'
+ return /bad credentials|unauthori[sz]ed|forbidden|expired|\b40[13]\b/i.test(String(e?.message || '')) ? 'refused' : 'other'
+}
+
+// The rows of the permission table: what to give the token for each mode.
+export const PERMISSIONS = [
+ { id: 'contents', read: 'read', write: 'read' },
+ { id: 'issues', read: 'read', write: 'readwrite' },
+ { id: 'pulls', read: 'read', write: 'readwrite' },
+ { id: 'metadata', read: 'auto', write: 'auto' },
+]
diff --git a/addons/github/ui/src/results.js b/addons/github/ui/src/results.js
new file mode 100644
index 0000000000..6418fd6069
--- /dev/null
+++ b/addons/github/ui/src/results.js
@@ -0,0 +1,30 @@
+// The results of the Test button, kept by connection so the row can draw them.
+// The shared page only shows one line of text at the top; the Test hook of
+// this page writes here, and the row reads. No React: a node test drives it.
+
+const results = new Map()
+const listeners = new Set()
+const emit = () => { for (const f of [...listeners]) f() }
+
+// Marks a connection as being tested and drops its old result.
+export function startTest(id) {
+ results.set(id, { busy: true, items: [], at: 0 })
+ emit()
+}
+
+// Stores the result of a finished test: items are { repo, tone, text }.
+export function finishTest(id, items, at = Date.now()) {
+ results.set(id, { busy: false, items, at })
+ emit()
+}
+
+export const resultOf = (id) => results.get(id) || null
+
+export function forget(id) {
+ if (results.delete(id)) emit()
+}
+
+export function subscribe(fn) {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
diff --git a/addons/github/ui/test/github.test.mjs b/addons/github/ui/test/github.test.mjs
index abe933a46b..1ce6274d49 100644
--- a/addons/github/ui/test/github.test.mjs
+++ b/addons/github/ui/test/github.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { checkLine, extraBody, extraForm, extraValid, githubSecret, parseRepos, reposProblem, tokenProblem, WRITE_MODES, writeModeOf } from '../src/repos.js'
+import { checkFailure, checkItem, checkLine, defaultName, extraBody, extraForm, extraValid, followName, githubSecret, parseRepos, PERMISSIONS, reposIssue, reposProblem, tokenProblem, WRITE_MODES, writeModeOf } from '../src/repos.js'
import { canSave, connectorPayload, emptyConnector } from '../../../internal/connector-ui/src/connector.js'
import en from '../src/i18n/github.en.json' with { type: 'json' }
@@ -13,16 +13,32 @@ test('github: only a fine-grained token is taken, and nothing typed keeps the sa
assert.equal(githubSecret({ token: 'ghp_classic' }), '')
})
-test('github: the repository list is read from lines, commas and spaces without repeats', () => {
- assert.deepEqual(parseRepos('acme/widgets\n Acme/Tools, acme/widgets x/y'), ['acme/widgets', 'Acme/Tools', 'x/y'])
+test('github: the repository list is read from lines and commas, never from spaces, without repeats', () => {
+ assert.deepEqual(parseRepos('acme/widgets\n Acme/Tools, acme/widgets\r\nx/y'), ['acme/widgets', 'Acme/Tools', 'x/y'])
+ assert.deepEqual(parseRepos('not a repo'), ['not a repo'], 'a space does not split an entry')
assert.deepEqual(parseRepos(''), [])
assert.equal(reposProblem('acme/widgets\nx/y'), '')
assert.equal(reposProblem('widgets'), 'reposShape')
assert.equal(reposProblem('a/b/c'), 'reposShape')
assert.equal(reposProblem('a/../b'), 'reposShape')
+ assert.equal(reposProblem('acme/widgets x/y'), 'reposShape')
assert.equal(reposProblem(Array.from({ length: 51 }, (_, i) => `o/r${i}`).join('\n')), 'reposMany')
})
+test('github: the error names the line that is wrong', () => {
+ assert.deepEqual(reposIssue('a/b\nnot a repo\nc/d'), { key: 'reposShape', entry: 'not a repo' })
+ assert.equal(reposIssue('a/b'), null)
+ assert.ok(reposIssue('x'.repeat(200)).entry.length <= 60)
+})
+
+test('github: the name follows the first repository until the person types one', () => {
+ assert.equal(defaultName('acme/widgets\nx/y'), 'GitHub: acme/widgets')
+ assert.equal(defaultName('nope'), '')
+ assert.equal(followName('', '', 'acme/widgets'), 'GitHub: acme/widgets')
+ assert.equal(followName('GitHub: acme/widgets', 'acme/widgets', 'acme/tools\nacme/widgets'), 'GitHub: acme/tools')
+ assert.equal(followName('Docs', 'acme/widgets', 'acme/tools'), 'Docs', 'a typed name stays')
+})
+
test('github: the list rides in the request, a binding is never switched on, and a saved one reads back', () => {
const form = { ...emptyConnector(), name: 'Work', agent_id: 'a', token: 'github_pat_x', ...extraForm({ resources: ['acme/widgets'] }) }
assert.equal(form.repos_text, 'acme/widgets')
@@ -38,15 +54,32 @@ test('github: the list rides in the request, a binding is never switched on, and
assert.equal(extraValid({ repos_text: 'nope' }), false)
})
-test('github: the Test lines say reachable, read only, or not reachable', () => {
- const t = (key, o = {}) => en.check[key.replace('check.', '')].replace(/\{\{(\w+)\}\}/g, (_, k) => o[k])
- assert.match(checkLine({ repo: 'a/b', reachable: false }, t), /not reachable/)
+test('github: the Test says reachable, read only, not reachable, refused or unfinished, each with a tone', () => {
+ const t = (key, o = {}) => key.split('.').reduce((n, k) => n[k], en).replace(/\{\{(\w+)\}\}/g, (_, k) => o[k])
+ assert.match(checkLine({ repo: 'a/b', reachable: false }, t), /cannot reach/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: false }, t), /read only/)
assert.match(checkLine({ repo: 'a/b', reachable: true, fullName: 'a/b', canWrite: true }, t), /can write/)
+ assert.match(checkLine({ repo: 'a/b', failed: 'refused' }, t), /refused this token.*expired/)
+ assert.equal(checkItem({ repo: 'a/b', reachable: true }).tone, 'ok')
+ assert.equal(checkItem({ repo: 'a/b', reachable: false }).tone, 'warn')
+ assert.equal(checkItem({ repo: 'a/b', failed: 'other' }).tone, 'warn')
+ assert.equal(checkFailure({ status: 401 }), 'refused')
+ assert.equal(checkFailure(new Error('Bad credentials')), 'refused')
+ assert.equal(checkFailure(new Error('timeout')), 'other')
+})
+
+test('github: the permission table covers the permissions the steps name', () => {
+ assert.deepEqual(PERMISSIONS.map((p) => p.id), ['contents', 'issues', 'pulls', 'metadata'])
+ for (const p of PERMISSIONS) {
+ assert.ok(en.setup.table[p.id], p.id)
+ assert.ok(en.setup.table.levels[p.read] && en.setup.table.levels[p.write], p.id)
+ }
+ assert.equal(PERMISSIONS.find((p) => p.id === 'issues').write, 'readwrite')
+ assert.equal(PERMISSIONS.find((p) => p.id === 'contents').write, 'read', 'writing never needs to push code')
})
test('github: every word the page asks for exists', () => {
- for (const key of ['title', 'intro', 'new', 'token', 'tokenHelp', 'tokenShape', 'tokenSpace', 'repos', 'reposHelp', 'reposShape', 'reposMany', 'writeMode', 'test', 'testOk', 'save', 'cancel', 'empty', 'noRepos', 'reposOf', 'agentOf', 'confirmDelete', 'setup']) {
+ for (const key of ['title', 'intro', 'new', 'token', 'tokenHelp', 'tokenShape', 'tokenSpace', 'repos', 'reposHelp', 'reposShape', 'reposMany', 'writeMode', 'test', 'testOk', 'save', 'cancel', 'empty', 'noRepos', 'reposOf', 'agentOf', 'confirmDelete', 'setup', 'limits', 'approval', 'check']) {
assert.ok(en[key], key)
}
})
@@ -80,3 +113,13 @@ test('github: a connection can be saved without an agent, and the page has words
assert.equal(connectorPayload(form, 'github', 'github_pat_x', { enabled: false }).agent_id, '')
for (const key of ['agentCredentialsOnly', 'agentOptionalHint', 'credentialsOnly']) assert.ok(en[key], key)
})
+
+test('github: the page says what it does not do and links the token page', () => {
+ assert.match(en.limits, /does not answer @mentions/)
+ assert.match(en.setup.linkLabel, /new tab/)
+})
+
+test('github: every write mode has a title and a consequence line', () => {
+ for (const m of WRITE_MODES) assert.ok(en.writeMode.line[m], m)
+ assert.notEqual(en.writeMode.auto, 'Allowed')
+})
diff --git a/core/http/react-ui/e2e/github-addon.spec.js b/core/http/react-ui/e2e/github-addon.spec.js
new file mode 100644
index 0000000000..e7682f6c70
--- /dev/null
+++ b/core/http/react-ui/e2e/github-addon.spec.js
@@ -0,0 +1,94 @@
+import { test, expect } from './coverage-fixtures.js'
+import { mockChat } from './chat-fixtures.js'
+import { installStudioAddons } from './studio-addon.js'
+import { connectorApi } from './connector-fixtures.js'
+
+// The page of the GitHub add-on (the real built bundle, inside the add-on
+// shell) with the core API stubbed: the token help, the repository list and
+// its errors, the write mode cards, and the Test result drawn in the row.
+
+const PAGE = '/app/addons/github/apps'
+const BINDING = { id: 'b1', name: 'Docs repo', kind: 'github', agent_id: 'a1', enabled: false, accept_any: false, allow: [], resources: ['acme/widgets', 'acme/docs'], write_mode: 'propose', has_token: true }
+const KIND = { capabilities: [], kindExtra: { title: 'GitHub', files: undefined } }
+const api = (page, initial = []) => connectorApi(page, initial, 'github', [], undefined, KIND)
+
+// The check tool answers through the add-on gateway of the page.
+const checkAnswer = (answer) => (body) => (body.arguments?.tool === 'check'
+ ? { status: 200, contentType: 'application/json', body: JSON.stringify({ payload: { data: answer(body.arguments.repo) } }) }
+ : null)
+
+test.describe('GitHub add-on', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockChat(page)
+ })
+ const install = (page, answer = () => ({ reachable: true })) => installStudioAddons(page, { ids: ['github'], dispatch: checkAnswer(answer) })
+
+ test('a first connection sees the token help open, a link to GitHub and the permission table', async ({ page }) => {
+ await install(page)
+ await api(page).install()
+ await page.goto(PAGE)
+ await expect(page.getByTestId('github-limits')).toContainText('does not answer @mentions')
+ await expect(page.getByTestId('github-setup')).toHaveAttribute('open', '')
+ const link = page.getByTestId('github-token-link').first()
+ await expect(link).toHaveAttribute('href', 'https://github.com/settings/personal-access-tokens/new')
+ await expect(link).toHaveAttribute('target', '_blank')
+ await expect(page.getByTestId('github-perms')).toContainText('Pull requests')
+ await expect(page.getByTestId('github-perms')).toContainText('Read and write')
+ })
+
+ test('with a connection the help is closed', async ({ page }) => {
+ await install(page)
+ await api(page, [BINDING]).install()
+ await page.goto(PAGE)
+ await expect(page.getByTestId('connector-row')).toContainText('Docs repo')
+ await expect(page.getByTestId('github-setup')).not.toHaveAttribute('open', '')
+ })
+
+ test('errors sit under their field, the bad line is named and the name follows the first repository', async ({ page }) => {
+ await install(page)
+ await api(page).install()
+ await page.goto(PAGE)
+ await page.getByRole('button', { name: 'New GitHub connection' }).click()
+ await page.getByLabel('Fine-grained access token').fill('ghp_classic')
+ await expect(page.getByLabel('Fine-grained access token')).toHaveAttribute('aria-invalid', 'true')
+ await expect(page.getByLabel('Fine-grained access token')).toHaveAccessibleDescription(/starts with github_pat_/)
+ await page.getByLabel('Repositories the agent may use').fill('acme/widgets\nnot a repo')
+ await expect(page.getByLabel('Repositories the agent may use')).toHaveAccessibleDescription(/"not a repo" is not written owner\/name/)
+ await expect(page.getByLabel('Name', { exact: true })).toHaveValue('GitHub: acme/widgets')
+ await page.getByLabel('Name', { exact: true }).fill('Mine')
+ await page.getByLabel('Repositories the agent may use').fill('acme/tools')
+ await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Mine')
+ await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
+ })
+
+ test('each write mode says what it does, and the default is read only', async ({ page }) => {
+ await install(page)
+ await api(page).install()
+ await page.goto(PAGE)
+ await page.getByRole('button', { name: 'New GitHub connection' }).click()
+ const group = page.getByRole('radiogroup', { name: 'What the agent may change on GitHub' })
+ await expect(group.getByRole('radio', { name: /Read only/ })).toBeChecked()
+ await expect(group).toContainText('Each change waits for your OK.')
+ await expect(group).toContainText('Acts without asking')
+ await expect(group).not.toContainText('Allowed')
+ })
+
+ test('the Test result is drawn per repository inside the row, with a tone, and a new run replaces it', async ({ page }) => {
+ let n = 0
+ await install(page, (repo) => {
+ n++
+ return repo === 'acme/docs' ? { reachable: false } : { reachable: true, fullName: repo, canWrite: n > 2 }
+ })
+ await api(page, [BINDING]).install()
+ await page.goto(PAGE)
+ await page.getByRole('button', { name: 'Test' }).click()
+ const row = page.getByTestId('connector-row')
+ await expect(row.getByTestId('github-checks')).toContainText('acme/widgets: reachable, read only.')
+ await expect(row.getByTestId('github-checks')).toContainText('acme/docs: this token cannot reach it')
+ await expect(row.locator('.gh-check--warn')).toHaveCount(1)
+ await expect(row.locator('.gh-check--ok')).toHaveCount(1)
+ await expect(page.getByText('Connected as support_bot.')).toBeVisible()
+ await page.getByRole('button', { name: 'Test' }).click()
+ await expect(row.getByTestId('github-checks')).toContainText('acme/widgets: reachable, and the token can write.')
+ })
+})
diff --git a/core/http/react-ui/e2e/studio-addon.js b/core/http/react-ui/e2e/studio-addon.js
index 683f4c9093..891971f0bb 100644
--- a/core/http/react-ui/e2e/studio-addon.js
+++ b/core/http/react-ui/e2e/studio-addon.js
@@ -25,7 +25,7 @@ export async function buildAddonBundles(ids) {
// The add-ons the specs serve from their build output: Studio, Talk and Voice
// through installStudioAddons, Face and Fine-tuning through their own routes.
-export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack']) {
+export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack', 'github']) {
await buildAddonBundles(ids)
process.env[BUILT] = '1'
}
@@ -55,7 +55,7 @@ export function packagedApp(id, over = {}) {
export async function installStudioAddons(page, { ids = ['studio', 'talk'], ...options } = {}) {
const dirs = await Promise.all(ids.map(build))
await stubAddonRoutes(page)
- await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack)\/assets\//, route => {
+ await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack|github)\/assets\//, route => {
const parts = new URL(route.request().url()).pathname.split('/')
const id = parts[3]
const file = parts.slice(6).join('/')
diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md
index b8480146df..57c0be066f 100644
--- a/docs/content/features/chats-connectors.md
+++ b/docs/content/features/chats-connectors.md
@@ -139,8 +139,10 @@ A binding says `active: false` and gives a `reason` while its add-on does not ru
The GitHub add-on lets your [saved agents](/features/chats-agents/) read repositories: search code, read files and folders, and read issues, pull requests and their diffs. If you allow it, they can also write: comment on issues and pull requests, open issues, change a title or close an issue, add labels, and comment on a line of a pull request diff. It cannot merge, delete, push code, approve or request changes on a review.
1. Install the **GitHub** add-on from the add-on gallery.
-2. On GitHub, make a **fine-grained personal access token** for only the repositories you want to share. Give it read access to Contents, Issues and Pull requests. To let the agent write, also give read and write access to Issues and Pull requests. Classic tokens are not accepted.
-3. On the add-on page, choose **New GitHub connection**. Paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches.
+2. On GitHub, make a **fine-grained personal access token** for only the repositories you want to share. The add-on page has a **Create a token on GitHub** button, and the steps and a table of the permissions are open there until you have a first connection. Give the token read access to Contents, Issues and Pull requests. To let the agent write, also give read and write access to Issues and Pull requests. Classic tokens are not accepted.
+3. On the add-on page, choose **New GitHub connection**. Paste the token and list the repositories, one `owner/name` per line or separated by commas (at most 50). The name of the connection defaults to the first repository; a line that is not `owner/name` is named under the list. Press **Test**: the row shows, for each listed repository, whether the token reaches it and whether it can write.
+
+This connection does not listen for `@mentions` on GitHub yet. An agent works on GitHub only when you ask it in a chat.
The agent field is optional. A connection without an agent shows **Credentials only** in the list: it holds the token, the repository list and the write mode for your chats, and it never answers a message. It cannot be switched on, take an allowlist, send notifications or hold permissions; the server refuses those with a message that asks for an agent. This is the manifest key `agentOptional` of the kind (see [Connector add-ons](/features/addons/)), which the GitHub kind declares; Telegram and Slack bindings always need an agent. Over the API, `agent_id` may be empty for such a kind and the connection reports `credentials_only: true`.
@@ -152,9 +154,9 @@ What GitHub returns (issue text, comments, file content) is written by other peo
On the connection you choose what the agent may change. The choice is stored with the connection and enforced by LocalAI before the add-on is called, so a model cannot raise it.
-- **Nothing (read only)** is the default. A write tool is refused and the agent is told to ask you to turn writes on.
-- **Ask me each time** stores each change as a proposal. You see the repository, the issue or pull request, its title and the text, and you confirm in the approvals of the web interface. A chat or an agent cannot confirm it.
-- **Allowed** lets a chat of yours make the change at once. A run without you present, such as an agent started by a trigger, still leaves a proposal for you to confirm.
+- **Read only** is the default. A write tool is refused and the agent is told to ask you to turn writes on.
+- **Asks you first** stores each change as a proposal. You see the repository, the issue or pull request, its title and the text, and you confirm in the approvals of the web interface. A chat or an agent cannot confirm it.
+- **Acts without asking** lets a chat of yours make the change at once. A run without you present, such as an agent started by a trigger, still leaves a proposal for you to confirm.
The write tools are `comment-issue`, `comment-pr`, `create-issue`, `edit-issue` (title, close or reopen), `add-labels` and `review-comment-pr`. They work only on the repositories listed on the connection, the same list as for reading. The change is also refused when the target moved after the preview: for example the title changed, the issue is locked, or a pull request has a new commit.