@goodandready/dsh-github-ops 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  Notable changes to `@goodandready/dsh-github-ops`.
4
4
 
5
+ ## 0.2.0
6
+
7
+ The development wave: reliability, reports, writing without a checkout, and the user surfaces.
8
+
9
+ ### Reliability and reach
10
+ - a short TTL read cache (`cacheTtlMs`) so a composed report does not spend the rate limit
11
+ twice; writes always go to the network and clear the cache;
12
+ - the turn's cancellation reaches the request in flight, and a cancelled call says so instead
13
+ of pretending to be a timeout; reads retry on a network failure, a timeout or a 5xx, and a
14
+ 429 is honoured through `Retry-After` or the rate-limit reset;
15
+ - every list is cut locally as well, because GitHub sometimes ignores the page size;
16
+ - failures say what to do next (401, 403, 404, 409, 422, timeouts, a broken `/etc/hosts`);
17
+ - `gh_help` lists every tool, grouped by area, built from the registrations;
18
+ - a short paragraph in the system prompt explains how to use them (`guidance`, `guidanceText`).
19
+
20
+ ### Reports
21
+ - `gh_repo_report`, `gh_weekly_digest`, `gh_notifications`, `gh_repo_health`, `gh_compare`,
22
+ `gh_trending`, `gh_contributors`, `gh_user_repos`, `gh_commits`.
23
+
24
+ ### Writing without a checkout, and signing in
25
+ - `gh_repo_tree`, `gh_push_files` (blobs, tree, commit, ref — `force` never implied),
26
+ `gh_upload_project`, `gh_delete_file`, `gh_delete_branch`;
27
+ - `gh_auth_login` / `gh_auth_finish` / `gh_auth_status` / `gh_auth_logout`: GitHub device flow,
28
+ stored in the plugin's own file with mode 0600 and never returned in a result. The access
29
+ chain is now credentials → environment → this plugin's sign-in → `gh` CLI.
30
+
31
+ ### User surfaces
32
+ - the access status in the plugin card (source, account, scopes, rate limit, cache) from a
33
+ local-only host route;
34
+ - a **Source Control** tab in the session view ring: repository header, change groups, diff
35
+ viewer, merge/rebase state, branches, tags and stashes — read-only for now;
36
+ - a **GitHub panel** in the sidebar (better-sidebar and the built-in right sidebar): repository
37
+ switcher, Code tree with file preview, Issues, Pull requests, Actions and an inbox;
38
+ - a pull request bar above the composer that hands an instruction to the composer instead of
39
+ writing by itself, and opt-in remembering of the repository behind a github.com link;
40
+ - `docs/design/DESIGN.md` describes all of it, with the states and the locked decisions.
41
+
5
42
  ## 0.1.2
6
43
 
7
44
  - Removed the write-approval contour the plugin never should have had: no `tools/pre-execute`
package/README.md CHANGED
@@ -67,6 +67,14 @@ Requires a GitHub token in the DSH credential service. Store the token under a n
67
67
  | `maxRetries` | `2` | Retries for a failed read (never for a write). |
68
68
  | `reviewRulesJson` | *(empty)* | Review-rule overrides as JSON: `sensitivePaths`, `sensitiveSeverity`, `attentionPaths`, `migrationPaths`, `testsRequired`, `sourcePatterns`, `testPatterns`, `largeDiffLines`. |
69
69
  | `reviewJobTimeoutMs` | `120000` | How long a background review job may run. |
70
+ | `cacheTtlMs` | `60000` | How long a read is served from the in-memory cache. Writes always go to the network and clear it. Set `0` to switch the cache off. |
71
+ | `interceptLinks` | `false` | Let a `github.com` link clicked in the conversation record which repository the Source Control tab and the panel should follow. The link always opens in the browser either way. |
72
+ | `guidance` | `true` | Add a short paragraph about this plugin to the system prompt. |
73
+ | `guidanceText` | *(empty)* | Replace that paragraph with your own text. |
74
+ | `oauthClientId` | the public `gh` CLI application | OAuth application used by `gh_auth_login`. |
75
+ | `oauthScope` | `repo workflow gist read:org` | Scope requested by `gh_auth_login`. |
76
+ | `oauthClientSecretRef` | *(empty)* | Optional credential name holding the OAuth client secret. |
77
+ | `accessFile` | `$DSH_HOME/github-ops-auth.json` | Where `gh_auth_login` stores the sign-in (written `0600`). |
70
78
 
71
79
  ## Tools
72
80
 
@@ -142,6 +150,42 @@ published because it is part of the package.
142
150
  | `gh_ruleset_list/view/apply/delete` | Repository rulesets: read, create, update, delete. |
143
151
  | `gh_branch_protection_get/set/delete` | Classic branch protection: required reviews, status checks, admin enforcement, force-push and deletion flags. |
144
152
 
153
+ ### Reports
154
+
155
+ | Tool | What it does |
156
+ |---|---|
157
+ | `gh_repo_report` | Everything about a repository in one call: overview, latest release, open issues, recent commits, top contributors. A failing part degrades that part instead of failing the report. |
158
+ | `gh_weekly_digest` | What happened inside a window: releases, new issues, merged or closed pull requests, commits. Pull requests are reported separately, not as “new issues”. |
159
+ | `gh_notifications` | The attention queue for the account — mentions, review requests, assignments — grouped by reason. |
160
+ | `gh_repo_health` | A transparent maintenance score: five weighted dimensions, each with its evidence, plus the risks found and concrete next actions. A heuristic over public signals, not a security audit. |
161
+ | `gh_compare` | Two repositories side by side with numeric deltas for stars, forks and open issues. |
162
+ | `gh_contributors` | Top contributors of a repository by commit count. |
163
+ | `gh_user_repos` | A user's or organization's repositories sorted by stars. |
164
+ | `gh_trending` | Recently created repositories by stars, optionally filtered by language (uses the search quota). |
165
+ | `gh_commits` | Recent commits, optionally since a timestamp and on a specific branch. |
166
+ | `gh_help` | Every tool of this plugin, grouped by area, built from the registrations so it cannot go stale. |
167
+
168
+ ### Files and branches
169
+
170
+ These publish content without a local checkout: the client builds a commit the way git does.
171
+
172
+ | Tool | What it does |
173
+ |---|---|
174
+ | `gh_repo_tree` | The file tree at a ref, recursively by default, with the truncation flag — far cheaper than walking directories. |
175
+ | `gh_push_files` | Commit files to a branch: blobs, a tree on top of the branch tree, a commit, then the ref moves. Creates the branch when it does not exist. `force` is never implied and needs `confirm: true`. |
176
+ | `gh_upload_project` | Create a repository, publish files into it and optionally open a pull request — one call. An existing repository is used as is instead of failing. |
177
+ | `gh_delete_file` | Delete one file (the blob sha is resolved first). Requires `confirm: true`. |
178
+ | `gh_delete_branch` | Delete a branch. Requires `confirm: true`. |
179
+
180
+ ### Account and sign-in
181
+
182
+ | Tool | What it does |
183
+ |---|---|
184
+ | `gh_auth_login` | Start a GitHub device-flow sign-in: returns a short code and a URL for the human. |
185
+ | `gh_auth_finish` | Collect a started sign-in. The granted value is written to this plugin's own file with mode `0600` and is never returned in a result. |
186
+ | `gh_auth_status` | Where the access comes from (credential, environment, this plugin's sign-in, `gh` CLI), the account, the reported scopes and the remaining rate limit. |
187
+ | `gh_auth_logout` | Forget the sign-in stored by this plugin; other sources are untouched. Requires `confirm: true`. |
188
+
145
189
  ## Background reviews and commands
146
190
 
147
191
  `gh_review_job` starts a review and returns a job id immediately; `gh_review_job_status`
@@ -160,6 +204,42 @@ Slash commands are the fast path for a human:
160
204
  A command never writes to GitHub itself: it hands the model an instruction, so the write
161
205
  still passes through the approval gate.
162
206
 
207
+ ## Reliability
208
+
209
+ - Reads are cached for `cacheTtlMs`, so a composed report or a repeated listing does not
210
+ spend the rate limit twice. Writes always reach the network and clear the cache.
211
+ - A cancelled turn cancels the request in flight, and the failure says so instead of
212
+ pretending to be a timeout.
213
+ - Reads retry on a network failure, a timeout or a server error. Writes never retry: a
214
+ repeated write is a decision, not an accident.
215
+ - Every list is also cut locally, because the API sometimes ignores the requested page size.
216
+ - A failure says what to do next: a missing credential, a missing scope, an object that is
217
+ invisible (a draft release is only reachable by id), a conflict to re-read, a payload to
218
+ fix, a timeout to retry — and a broken `/etc/hosts` entry when the network is unreachable.
219
+
220
+ ## User interface
221
+
222
+ The plugin contributes four small surfaces, all read-only except the two documented
223
+ confirmations:
224
+
225
+ - **Access status in the plugin card.** Source, account, scopes, remaining rate limit and
226
+ cache state. It is loaded from a local-only host route, so the access value never enters
227
+ the conversation. States: loading, not configured (with guidance), ok, error.
228
+ - **Source Control tab** in the session view ring: repository path and branch with
229
+ ahead/behind, change groups (conflicts, staged, changes, untracked), a diff pane for the
230
+ selected file, an in-progress merge/rebase banner, and branches, tags and stashes. Reading
231
+ only for now: the commit bar and the branch/stash actions are the next step.
232
+ - **GitHub panel** in the sidebar — it registers in better-sidebar and in the built-in right
233
+ sidebar. Repository switcher with public search, a Code tree with a file preview, and
234
+ Issues, Pull requests, Actions and Inbox tabs.
235
+ - **Pull request bar** above the composer. It appears when the branch has commits the
236
+ upstream does not, and its buttons put an instruction on the clipboard instead of writing:
237
+ the call still goes through the normal approval contour.
238
+
239
+ A short paragraph about the plugin is added to the system prompt (`guidance`), and a
240
+ `github.com` link clicked in the conversation can record which repository to follow
241
+ (`interceptLinks`, off by default).
242
+
163
243
  ## Safety
164
244
 
165
245
  - The token lives in the DSH credential service; settings hold only its name.
@@ -193,6 +273,17 @@ Tests live next to the code they cover and never touch the network: the transpor
193
273
  parameter, so failure modes (404, rate limit, timeout, non-JSON body, short pages) are
194
274
  exercised directly.
195
275
 
276
+ ## Visual verification
277
+
278
+ The settings card, rendered by this release candidate on an isolated test server and captured
279
+ from the real interface in both themes:
280
+
281
+ ![GitHub ops settings card, dark and light theme](media/visual-verification.png)
282
+
283
+ What the picture shows: the plugin card with its title and description, the credential-name and
284
+ access-source fields with their hints, and the access block the card loads from the host route.
285
+ Both frames come from the same candidate and the same data.
286
+
196
287
  ## License
197
288
 
198
289
  MIT — see [LICENSE](LICENSE).
package/README.ru.md CHANGED
@@ -67,6 +67,14 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
67
67
  | `maxRetries` | `2` | Повторы для неудачного чтения (запись не повторяется). |
68
68
  | `reviewRulesJson` | пусто | Переопределение правил ревью в JSON: `sensitivePaths`, `sensitiveSeverity`, `attentionPaths`, `migrationPaths`, `testsRequired`, `sourcePatterns`, `testPatterns`, `largeDiffLines`. |
69
69
  | `reviewJobTimeoutMs` | `120000` | Сколько может работать фоновая задача ревью. |
70
+ | `cacheTtlMs` | `60000` | Сколько живёт прочитанное в кэше. Записи всегда идут в сеть и сбрасывают кэш. `0` выключает кэш. |
71
+ | `interceptLinks` | `false` | Разрешить ссылке `github.com` из чата запоминать репозиторий, за которым следуют вкладка Source Control и панель. Сама ссылка в любом случае открывается в браузере. |
72
+ | `guidance` | `true` | Добавлять короткий абзац о плагине в системный промпт. |
73
+ | `guidanceText` | *(пусто)* | Заменить этот абзац своим текстом. |
74
+ | `oauthClientId` | публичное приложение `gh` CLI | OAuth-приложение, которым пользуется `gh_auth_login`. |
75
+ | `oauthScope` | `repo workflow gist read:org` | Скоупы, которые запрашивает `gh_auth_login`. |
76
+ | `oauthClientSecretRef` | *(пусто)* | Необязательное имя креденшела с секретом OAuth-приложения. |
77
+ | `accessFile` | `$DSH_HOME/github-ops-auth.json` | Где `gh_auth_login` хранит вход (пишется с правами `0600`). |
70
78
 
71
79
  ## Инструменты
72
80
 
@@ -149,6 +157,42 @@ Allowlist — состав `package.json → files` плюс `.gitignore`, `LICE
149
157
  таймаут. Карточка проверяет **статус** снимка настроек и честно сообщает, когда сервис
150
158
  настроек недоступен, вместо того чтобы рисовать рабочий на вид формы.
151
159
 
160
+ ### Отчёты
161
+
162
+ | Инструмент | Что делает |
163
+ |---|---|
164
+ | `gh_repo_report` | Всё о репозитории одним вызовом: обзор, последний релиз, открытые issues, свежие коммиты, основные контрибьюторы. Если часть не удалась, деградирует только она, а не весь отчёт. |
165
+ | `gh_weekly_digest` | Что случилось за окно: релизы, новые issues, слитые и закрытые PR, коммиты. PR идут отдельно и не выдаются за «новые issues». |
166
+ | `gh_notifications` | Очередь внимания по аккаунту — упоминания, запросы ревью, назначения — сгруппированная по причинам. |
167
+ | `gh_repo_health` | Прозрачная оценка сопровождения: пять измерений с доказательством по каждому, найденные риски и конкретные следующие действия. Это эвристика по публичным сигналам, а не аудит безопасности. |
168
+ | `gh_compare` | Два репозитория рядом с числовыми разницами по звёздам, форкам и открытым issues. |
169
+ | `gh_contributors` | Основные контрибьюторы репозитория по числу коммитов. |
170
+ | `gh_user_repos` | Репозитории пользователя или организации по звёздам. |
171
+ | `gh_trending` | Недавно созданные репозитории по звёздам, при желании — по языку (тратит квоту поиска). |
172
+ | `gh_commits` | Свежие коммиты, при желании — начиная с даты и в конкретной ветке. |
173
+ | `gh_help` | Все инструменты плагина по группам; каталог собирается из регистраций и не может устареть. |
174
+
175
+ ### Файлы и ветки
176
+
177
+ Публикуют содержимое без локальной копии: коммит собирается так же, как это делает git.
178
+
179
+ | Инструмент | Что делает |
180
+ |---|---|
181
+ | `gh_repo_tree` | Дерево файлов на указанном ref, по умолчанию рекурсивно, с флагом обрезки — намного дешевле обхода каталогов. |
182
+ | `gh_push_files` | Коммит файлов в ветку: blob'ы, дерево поверх дерева ветки, коммит, затем сдвиг ref. Ветка создаётся, если её нет. `force` никогда не подразумевается и требует `confirm: true`. |
183
+ | `gh_upload_project` | Создать репозиторий, залить файлы и при желании открыть PR — одним вызовом. Существующий репозиторий используется как есть, а не роняет вызов. |
184
+ | `gh_delete_file` | Удалить файл (сначала резолвится blob sha). Требует `confirm: true`. |
185
+ | `gh_delete_branch` | Удалить ветку. Требует `confirm: true`. |
186
+
187
+ ### Аккаунт и вход
188
+
189
+ | Инструмент | Что делает |
190
+ |---|---|
191
+ | `gh_auth_login` | Начать вход по GitHub device flow: возвращает короткий код и ссылку для человека. |
192
+ | `gh_auth_finish` | Забрать начатый вход. Выданное значение пишется в файл плагина с правами `0600` и никогда не возвращается в результате. |
193
+ | `gh_auth_status` | Откуда взят доступ (креденшел, окружение, вход этого плагина, `gh` CLI), аккаунт, выданные скоупы и остаток лимита. |
194
+ | `gh_auth_logout` | Забыть вход, сохранённый этим плагином; остальные источники не трогаются. Требует `confirm: true`. |
195
+
152
196
  ## Фоновые обзоры и команды
153
197
 
154
198
  `gh_review_job` запускает обзор и сразу возвращает id задачи, `gh_review_job_status` читает
@@ -160,6 +204,43 @@ Allowlist — состав `package.json → files` плюс `.gitignore`, `LICE
160
204
  в GitHub сама: она передаёт модели инструкцию, поэтому запись всё равно проходит через
161
205
  подтверждение.
162
206
 
207
+ ## Надёжность
208
+
209
+ - Прочитанное кэшируется на `cacheTtlMs`, поэтому составной отчёт или повторный список не
210
+ тратят лимит дважды. Записи всегда идут в сеть и сбрасывают кэш.
211
+ - Отменённый ход отменяет запрос в полёте, и в ошибке так и сказано — вместо маскировки под
212
+ таймаут.
213
+ - Чтения повторяются при сбое сети, таймауте или ошибке сервера. Записи не повторяются
214
+ никогда: повторная запись — это решение, а не случайность.
215
+ - Каждый список дополнительно режется локально: API иногда игнорирует запрошенный размер
216
+ страницы.
217
+ - Ошибка говорит, что делать дальше: нет доступа, не хватает скоупа, объект невидим
218
+ (черновик релиза доступен только по id), конфликт — перечитать, payload — исправить,
219
+ таймаут — повторить; а при недоступной сети прямо называется битая запись `/etc/hosts`.
220
+
221
+ ## Интерфейс
222
+
223
+ Плагин добавляет четыре небольшие поверхности; все они только читают, кроме двух явных
224
+ подтверждений:
225
+
226
+ - **Статус доступа в карточке плагина.** Источник, аккаунт, скоупы, остаток лимита и
227
+ состояние кэша. Грузится с локального хост-маршрута, поэтому значение доступа не попадает
228
+ в переписку. Состояния: загрузка, не настроено (с подсказкой), всё в порядке, ошибка.
229
+ - **Вкладка Source Control** в кольце вкладок сессии: путь и ветка с ahead/behind, группы
230
+ изменений (конфликты, проиндексированные, изменённые, неотслеживаемые), панель диффа для
231
+ выбранного файла, баннер незавершённых merge/rebase, ветки, теги и stash. Пока только
232
+ чтение: строка коммита и действия с ветками и stash — следующий шаг.
233
+ - **Панель GitHub** в сайдбаре — регистрируется в better-sidebar и во встроенном правом
234
+ сайдбаре. Переключатель репозиториев с поиском публичных, дерево Code с просмотром файла
235
+ и вкладки Issues, Pull requests, Actions, Inbox.
236
+ - **Полоса PR** над полем ввода. Появляется, когда в ветке есть коммиты, которых нет в
237
+ upstream, а её кнопки кладут инструкцию в буфер обмена вместо записи: вызов всё равно идёт
238
+ через штатный контур подтверждений.
239
+
240
+ Короткий абзац о плагине добавляется в системный промпт (`guidance`), а клик по ссылке
241
+ `github.com` из чата может запомнить, за каким репозиторием следить (`interceptLinks`, по
242
+ умолчанию выключено).
243
+
163
244
  ## Безопасность
164
245
 
165
246
  - Токен живёт в сервисе учётных данных DSH; в настройках только его имя.
package/README.zh.md CHANGED
@@ -64,6 +64,14 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
64
64
  | `maxRetries` | `2` | 读取失败时的重试次数(写入不重试)。 |
65
65
  | `reviewRulesJson` | 空 | 以 JSON 覆盖评审规则:`sensitivePaths`、`sensitiveSeverity`、`attentionPaths`、`migrationPaths`、`testsRequired`、`sourcePatterns`、`testPatterns`、`largeDiffLines`。 |
66
66
  | `reviewJobTimeoutMs` | `120000` | 后台评审任务的最长运行时间。 |
67
+ | `cacheTtlMs` | `60000` | 读取结果在内存缓存中的存活时间。写入始终访问网络并清空缓存;设为 `0` 关闭缓存。 |
68
+ | `interceptLinks` | `false` | 允许会话中点击的 `github.com` 链接记录仓库,供 Source Control 标签页与侧边栏面板跟随。无论如何链接都会在浏览器中打开。 |
69
+ | `guidance` | `true` | 在系统提示中加入一段关于本插件的简短说明。 |
70
+ | `guidanceText` | *(空)* | 用自定义文本替换该说明。 |
71
+ | `oauthClientId` | 公开的 `gh` CLI 应用 | `gh_auth_login` 使用的 OAuth 应用。 |
72
+ | `oauthScope` | `repo workflow gist read:org` | `gh_auth_login` 请求的权限范围。 |
73
+ | `oauthClientSecretRef` | *(空)* | 可选的、保存 OAuth 客户端密钥的凭据名称。 |
74
+ | `accessFile` | `$DSH_HOME/github-ops-auth.json` | `gh_auth_login` 保存登录信息的位置(权限 `0600`)。 |
67
75
 
68
76
  ## 工具
69
77
 
@@ -143,6 +151,42 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
143
151
  `settings.plugin.item`):凭据名称、默认仓库、API 地址、请求超时。卡片会检查设置快照的
144
152
  **状态**,在设置服务不可用时明确说明,而不是画出一个看似可用的表单。
145
153
 
154
+ ### 报告
155
+
156
+ | 工具 | 作用 |
157
+ |---|---|
158
+ | `gh_repo_report` | 一次调用给出仓库全貌:概览、最新 Release、打开的 Issue、近期提交、主要贡献者。某一部分失败只影响该部分,不会让整份报告失败。 |
159
+ | `gh_weekly_digest` | 时间窗口内发生了什么:Release、新 Issue、已合并或关闭的 PR、提交。PR 单独统计,不会被当作“新 Issue”。 |
160
+ | `gh_notifications` | 账号的关注队列——提及、评审请求、指派——按原因分组。 |
161
+ | `gh_repo_health` | 透明的维护评分:五个加权维度,每项都带证据,另有风险与具体下一步。它是基于公开信号的启发式判断,不是安全审计。 |
162
+ | `gh_compare` | 两个仓库并排比较,给出星标、Fork、打开 Issue 的数值差异。 |
163
+ | `gh_contributors` | 按提交数排列的主要贡献者。 |
164
+ | `gh_user_repos` | 用户或组织的仓库,按星标排序。 |
165
+ | `gh_trending` | 近期创建的热门仓库,可按语言筛选(消耗搜索配额)。 |
166
+ | `gh_commits` | 近期提交,可按时间与分支过滤。 |
167
+ | `gh_help` | 本插件的全部工具,按领域分组,由注册表生成,不会过期。 |
168
+
169
+ ### 文件与分支
170
+
171
+ 无需本地检出即可发布内容:提交按 git 的方式组装。
172
+
173
+ | 工具 | 作用 |
174
+ |---|---|
175
+ | `gh_repo_tree` | 指定 ref 的文件树,默认递归,并给出截断标志——比逐层遍历目录便宜得多。 |
176
+ | `gh_push_files` | 向分支提交文件:blob → 基于分支树的 tree → commit → 移动 ref。分支不存在会创建。`force` 绝不隐含,且需要 `confirm: true`。 |
177
+ | `gh_upload_project` | 创建仓库、发布文件并可选地打开 PR——一次调用。仓库已存在时直接使用,而不是失败。 |
178
+ | `gh_delete_file` | 删除单个文件(先解析 blob sha)。需要 `confirm: true`。 |
179
+ | `gh_delete_branch` | 删除分支。需要 `confirm: true`。 |
180
+
181
+ ### 账号与登录
182
+
183
+ | 工具 | 作用 |
184
+ |---|---|
185
+ | `gh_auth_login` | 启动 GitHub device flow 登录:返回短码与供人工确认的网址。 |
186
+ | `gh_auth_finish` | 收取已启动的登录。授权值写入本插件自己的文件(权限 `0600`),绝不会出现在工具结果里。 |
187
+ | `gh_auth_status` | 访问来源(凭据、环境变量、本插件登录、`gh` CLI)、账号、已授权范围与剩余速率限制。 |
188
+ | `gh_auth_logout` | 忘记本插件保存的登录;其他来源不受影响。需要 `confirm: true`。 |
189
+
146
190
  ## 后台评审与斜杠命令
147
191
 
148
192
  `gh_review_job` 立即返回任务 id,`gh_review_job_status` 读取结果。宿主提供任务注册表时,
@@ -152,6 +196,25 @@ dsh plugin --profile web add @goodandready/dsh-github-ops
152
196
  `/issue new <title> | list | show <number>`、`/gh [分组|工具]`。命令本身**不会**写入 GitHub,
153
197
  只是把指令交给模型,因此写入仍然经过审批关卡。
154
198
 
199
+ ## 可靠性
200
+
201
+ - 读取结果按 `cacheTtlMs` 缓存,因此组合报告或重复列表不会重复消耗速率限制。写入始终访问网络并清空缓存。
202
+ - 被取消的回合会取消在途请求,错误会如实说明,而不是伪装成超时。
203
+ - 读取会在网络失败、超时或服务端错误时重试;写入从不重试——重复写入是决策,不是意外。
204
+ - 每个列表同时会在本地截断,因为 API 有时会忽略请求的分页大小。
205
+ - 失败会说明下一步该做什么:缺少凭据、权限范围不足、对象不可见(草稿 Release 只能按 id 访问)、需要重新读取的冲突、需要修正的载荷、可以重试的超时;网络不可达时还会指出 `/etc/hosts` 被写坏。
206
+
207
+ ## 界面
208
+
209
+ 插件提供四个小界面,除两处明确确认外均为只读:
210
+
211
+ - **插件卡片中的访问状态。** 来源、账号、权限范围、剩余速率限制与缓存状态。数据来自仅本机可访问的宿主路由,访问值不会进入对话。状态:加载中、未配置(附指引)、正常、错误。
212
+ - **Source Control 标签页**(会话视图环):仓库路径与分支的 ahead/behind、变更分组(冲突、已暂存、已修改、未跟踪)、所选文件的差异面板、进行中的 merge/rebase 提示,以及分支、标签与 stash。目前只读:提交栏与分支/stash 操作是下一步。
213
+ - **侧边栏 GitHub 面板**——同时注册到 better-sidebar 与内置右侧栏。带公开搜索的仓库切换器、带文件预览的 Code 树,以及 Issues、Pull requests、Actions、Inbox 四个标签。
214
+ - **输入框上方的 Pull Request 条**。当分支存在上游没有的提交时出现;其按钮把指令复制到剪贴板而不是直接写入——调用仍走正常的审批链路。
215
+
216
+ 系统提示中会加入一段关于本插件的简短说明(`guidance`);会话中点击的 `github.com` 链接可以记录要跟随的仓库(`interceptLinks`,默认关闭)。
217
+
155
218
  ## 安全边界
156
219
 
157
220
  - token 只存在于 DSH 凭据服务,设置中只有名称;
@@ -0,0 +1,150 @@
1
+ // Signing in without a terminal.
2
+ //
3
+ // GitHub's device flow: ask for a code, show it to the human, poll until they approve. The
4
+ // polling loop takes its clock and its transport as parameters, so it is testable without
5
+ // waiting a minute or touching the network.
6
+ //
7
+ // The granted value is written to this plugin's own file with mode 0600 (the DSH credentials
8
+ // service is the preferred home, but it is not writable by a plugin). The value is never
9
+ // logged, never echoed in an error, and never returned in a tool result.
10
+
11
+ import { promises as fs } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ /** GitHub CLI's public OAuth application id: the one the gh CLI itself signs in with. */
16
+ export const DEFAULT_CLIENT_ID = '178c6fc778ccc68e1d6a'
17
+ export const DEFAULT_SCOPE = 'repo workflow gist read:org'
18
+
19
+ export const DEVICE_CODE_URL = 'https://github.com/login/device/code'
20
+ export const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
21
+
22
+ /** Where a device-flow sign-in is stored. `$DSH_HOME` wins, then `~/.dsh`. */
23
+ export function accessFilePath(env = process.env) {
24
+ const home = env.DSH_HOME && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : join(homedir(), '.dsh')
25
+ return join(home, 'github-ops-auth.json')
26
+ }
27
+
28
+ async function postJson(fetchImpl, url, body, accept = 'application/json') {
29
+ const response = await fetchImpl(url, {
30
+ method: 'POST',
31
+ headers: { accept, 'content-type': 'application/json', 'user-agent': 'dsh-github-ops' },
32
+ body: JSON.stringify(body),
33
+ })
34
+ const text = typeof response.text === 'function' ? await response.text() : ''
35
+ let payload = {}
36
+ if (text) {
37
+ try {
38
+ payload = JSON.parse(text)
39
+ } catch {
40
+ const params = new URLSearchParams(text)
41
+ payload = Object.fromEntries(params.entries())
42
+ }
43
+ }
44
+ if (!response.ok) {
45
+ const message = payload.error_description || payload.error || `HTTP ${response.status}`
46
+ const err = new Error(String(message))
47
+ err.status = response.status
48
+ throw err
49
+ }
50
+ return payload
51
+ }
52
+
53
+ /** Ask GitHub for a user code. Nothing is stored yet. */
54
+ export async function startDeviceFlow({ clientId = DEFAULT_CLIENT_ID, scope = DEFAULT_SCOPE, fetchImpl = globalThis.fetch } = {}) {
55
+ if (typeof fetchImpl !== 'function') throw new Error('no fetch implementation available')
56
+ const payload = await postJson(fetchImpl, DEVICE_CODE_URL, { client_id: clientId, scope })
57
+ if (!payload.device_code || !payload.user_code) {
58
+ throw new Error('GitHub did not return a device code')
59
+ }
60
+ return {
61
+ deviceCode: payload.device_code,
62
+ userCode: payload.user_code,
63
+ verificationUri: payload.verification_uri || payload.verification_uri_complete || 'https://github.com/login/device',
64
+ expiresInSeconds: Number(payload.expires_in) || 900,
65
+ intervalSeconds: Number(payload.interval) || 5,
66
+ scope,
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Poll until the human approves, the code expires, or the wait budget runs out.
72
+ *
73
+ * @returns {{status: 'authorized', token: string, scope: string} | {status: 'pending'|'expired'|'denied', message: string}}
74
+ */
75
+ export async function pollDeviceFlow({
76
+ clientId = DEFAULT_CLIENT_ID,
77
+ deviceCode,
78
+ clientSecret,
79
+ fetchImpl = globalThis.fetch,
80
+ sleepImpl,
81
+ intervalSeconds = 5,
82
+ maxWaitMs = 120000,
83
+ } = {}) {
84
+ if (!deviceCode) throw new Error('deviceCode is required')
85
+ const sleep = typeof sleepImpl === 'function'
86
+ ? sleepImpl
87
+ : (ms) => new Promise((resolve) => setTimeout(resolve, ms))
88
+ let interval = Math.max(1, Number(intervalSeconds) || 5)
89
+ const deadline = Date.now() + Math.max(1000, Number(maxWaitMs) || 120000)
90
+
91
+ while (Date.now() < deadline) {
92
+ const body = { client_id: clientId, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }
93
+ if (clientSecret) body.client_secret = clientSecret
94
+ let payload
95
+ try {
96
+ payload = await postJson(fetchImpl, ACCESS_TOKEN_URL, body)
97
+ } catch (err) {
98
+ // the endpoint answers 200 with an error field; a transport failure is a real failure
99
+ return { status: 'denied', message: String((err && err.message) || err) }
100
+ }
101
+ if (payload.access_token) {
102
+ return { status: 'authorized', token: String(payload.access_token), scope: String(payload.scope || '') }
103
+ }
104
+ const error = String(payload.error || '')
105
+ if (error === 'authorization_pending') {
106
+ await sleep(interval * 1000)
107
+ continue
108
+ }
109
+ if (error === 'slow_down') {
110
+ interval += 5
111
+ await sleep(interval * 1000)
112
+ continue
113
+ }
114
+ if (error === 'expired_token') return { status: 'expired', message: 'the code expired: start the sign-in again' }
115
+ if (error === 'access_denied') return { status: 'denied', message: 'the sign-in was denied' }
116
+ return { status: 'denied', message: payload.error_description || error || 'unexpected answer from GitHub' }
117
+ }
118
+ return { status: 'pending', message: 'still waiting for approval: ask again to keep polling' }
119
+ }
120
+
121
+ /** Store a granted value. Written 0600 and never returned to the caller. */
122
+ export async function writeAccessFile(path, { token, login = '', scope = '', source = 'device-flow' } = {}) {
123
+ if (!token) throw new Error('token is required')
124
+ await fs.mkdir(dirname(path), { recursive: true })
125
+ const record = { token, login, scope, source, savedAt: new Date().toISOString() }
126
+ await fs.writeFile(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
127
+ await fs.chmod(path, 0o600).catch(() => {})
128
+ return { path, login, scope }
129
+ }
130
+
131
+ /** Read the stored sign-in, or null when there is none. */
132
+ export async function readAccessFile(path) {
133
+ try {
134
+ const text = await fs.readFile(path, 'utf8')
135
+ const parsed = JSON.parse(text)
136
+ return parsed && parsed.token ? parsed : null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ /** Forget the stored sign-in. */
143
+ export async function clearAccessFile(path) {
144
+ try {
145
+ await fs.rm(path, { force: true })
146
+ return { removed: true, path }
147
+ } catch (err) {
148
+ return { removed: false, path, error: String((err && err.message) || err) }
149
+ }
150
+ }
package/lib/auth.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Access resolution across the sources a GitHub user actually has.
2
2
  //
3
- // Order (tokenSource: "auto"): the DSH credentials service, then the environment
4
- // variable of the same name, then the `gh` CLI session. The credential service stays
3
+ // Order (tokenSource: "auto"): the DSH credentials service, then the environment variable of
4
+ // the same name, then the sign-in this plugin stored itself (gh_auth_login), then the `gh` CLI
5
+ // session. The credential service stays
5
6
  // the recommended source — it is the only one DSH manages and rotates — but a machine
6
7
  // with `gh auth login` already done should not need a second copy of the same secret.
7
8
  //
@@ -10,9 +11,10 @@
10
11
 
11
12
  export const NO_ACCESS_GUIDANCE = 'Configure one of: 1) a DSH credential with this name '
12
13
  + '($DSH_HOME/.credentials.yaml) — recommended, 2) the same-named environment variable, '
13
- + '3) `gh auth login` for the gh CLI. Set tokenSource in the plugin settings to pin one source.'
14
+ + '3) gh_auth_login to sign in from here, 4) `gh auth login` for the gh CLI. '
15
+ + 'Set tokenSource in the plugin settings to pin one source.'
14
16
 
15
- export const ACCESS_SOURCES = ['credentials', 'env', 'gh']
17
+ export const ACCESS_SOURCES = ['credentials', 'env', 'file', 'gh']
16
18
 
17
19
  /**
18
20
  * Resolve access for one operation. Nothing is cached: a rotated credential reaches the
@@ -25,7 +27,7 @@ export const ACCESS_SOURCES = ['credentials', 'env', 'gh']
25
27
  * @param runGh - async () => string; returns the gh CLI access value, '' when unavailable
26
28
  * @returns {Promise<{ value: string, source: string, guidance: string }>}
27
29
  */
28
- export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto', env = process.env, runGh } = {}) {
30
+ export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto', env = process.env, runGh, runFile } = {}) {
29
31
  const order = tokenSource === 'auto' ? ACCESS_SOURCES : [tokenSource]
30
32
  for (const source of order) {
31
33
  if (source === 'credentials') {
@@ -45,6 +47,16 @@ export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto
45
47
  if (value) return { value, source, guidance: '' }
46
48
  continue
47
49
  }
50
+ if (source === 'file') {
51
+ let value = ''
52
+ try {
53
+ value = runFile ? String((await runFile()) || '').trim() : ''
54
+ } catch {
55
+ value = ''
56
+ }
57
+ if (value) return { value, source, guidance: '' }
58
+ continue
59
+ }
48
60
  if (source === 'gh') {
49
61
  let value = ''
50
62
  try {