@goodandready/dsh-kanban 0.2.14 → 0.2.15

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/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  Managing concurrent software tasks across multiple autonomous AI agents easily leads to context drift, lost pull request handoffs, and untracked branch lifecycles. Without a dedicated visual board, users must manually juggle disparate chat windows and verify issue states on remote VCS trackers.
41
41
 
42
- **`@goodandready/dsh-kanban`** introduces an interactive **Kanban Board** embedded natively into DeepSeek Harness. Every card represents a concrete development task linked to its own **dedicated agent session**. Moving a card between workflow columns automatically dispatches lifecycle instructions directly to the agent (e.g. implementing, opening PRs, deploying, or cleaning up worktrees), with continuous **2-way Gitea/Forgejo synchronization**.
42
+ **`@goodandready/dsh-kanban`** introduces an interactive **Kanban Board** embedded natively into DeepSeek Harness. Every card represents a concrete development task linked to its own **dedicated agent session**. Moving a card between workflow columns automatically dispatches lifecycle instructions directly to the agent (e.g. implementing, opening PRs, deploying, or cleaning up worktrees), with continuous **2-way Gitea, Forgejo, and GitHub synchronization**.
43
43
 
44
44
  ---
45
45
 
@@ -64,7 +64,7 @@ graph LR
64
64
  Agent2["Agent: Task #53<br/>(PR Review)"]
65
65
  end
66
66
 
67
- subgraph VCS ["Gitea / Forgejo Instance"]
67
+ subgraph VCS ["Git Forge (Gitea / GitHub)"]
68
68
  GiteaIssues["Issues & Milestones"]
69
69
  GiteaPRs["Pull Requests & Branches"]
70
70
  Webhook["Event Webhook (/dsh-kanban/webhook)"]
@@ -109,7 +109,7 @@ Unlike passive task boards, `dsh-kanban` actively drives the agents:
109
109
 
110
110
  ---
111
111
 
112
- ### 3. Continuous 2-Way Gitea / Forgejo Sync
112
+ ### 3. Continuous 2-Way Gitea / Forgejo & GitHub Sync
113
113
 
114
114
  * **Bi-directional Reconciliation**: Polling and Webhook receiver (`/dsh-kanban/webhook`) sync issue descriptions, comments, PR status, labels, and closed states.
115
115
  * **Conflict Resolution**: Timestamp-based merge logic ensures manual edits in Gitea and board moves resolve cleanly without state clobbering.
@@ -207,6 +207,10 @@ dsh-kanban:
207
207
  | Key | Type | Default | Description |
208
208
  |:---|:---|:---|:---|
209
209
  | `boardKind` | `string` | `"project"` | Board workflow mode: `"project"` (6 columns) or `"simple"` (4 columns) |
210
+ | `gitProvider` | `string` | `"auto"` | Active Git forge: `"auto"` (detect by credentials), `"gitea"`, or `"github"` |
211
+ | `githubTokenRef` | `string` | `""` | DSH credential name containing GitHub PAT or App token |
212
+ | `githubOwner` | `string` | `""` | Default GitHub organization or username |
213
+ | `githubWebhookSecretRef` | `string` | `""` | DSH credential name containing GitHub webhook secret |
210
214
  | `giteaUrl` | `string` | `""` | Base URL of Gitea/Forgejo instance for task synchronization |
211
215
  | `giteaTokenEnv` | `string` | `"GITEA_TOKEN"` | DSH Credential name containing the Gitea API access token |
212
216
  | `giteaOwner` | `string` | `""` | Default organization or username for synced repositories |
package/README.ru.md CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  При ведении параллельной разработки силами нескольких автономных AI-агентов легко потерять контроль над статусом задач, проверкой пулл-реквестов (PR) и ветками в репозитории. Без наглядной доски разработчику приходится переключаться между десятками вкладок чата и вручную сверять тикеты в трекере.
41
41
 
42
- **`@goodandready/dsh-kanban`** встраивает полноценную **Канбан-доску** прямо в Web UI DeepSeek Harness. Каждая карточка на доске связана со своей **выделенной сессией агента**. Перемещение карточки между колонками автоматически отправляет агенту целевую инструкцию (разработка, передача на ревью, деплой или очистка воркдеревьев), поддерживая непрерывную **двустороннюю синхронизацию с Gitea / Forgejo**.
42
+ **`@goodandready/dsh-kanban`** встраивает полноценную **Канбан-доску** прямо в Web UI DeepSeek Harness. Каждая карточка на доске связана со своей **выделенной сессией агента**. Перемещение карточки между колонками автоматически отправляет агенту целевую инструкцию (разработка, передача на ревью, деплой или очистка воркдеревьев), поддерживая непрерывную **двустороннюю синхронизацию с Gitea / Forgejo и GitHub**.
43
43
 
44
44
  ---
45
45
 
@@ -64,7 +64,7 @@ graph LR
64
64
  Agent2["Агент: Задача #53<br/>(Ревью PR)"]
65
65
  end
66
66
 
67
- subgraph VCS ["Сервер Gitea / Forgejo"]
67
+ subgraph VCS ["Git Forge (Gitea / GitHub)"]
68
68
  GiteaIssues["Задачи и Вехи"]
69
69
  GiteaPRs["Пулл-реквесты и Ветки"]
70
70
  Webhook["Вебхук-приёмник (/dsh-kanban/webhook)"]
@@ -159,6 +159,10 @@ dsh-kanban:
159
159
  | Параметр | Тип | По умолчанию | Описание |
160
160
  |:---|:---|:---|:---|
161
161
  | `boardKind` | `string` | `"project"` | Режим колонок: `"project"` (6 колонок) или `"simple"` (4 колонки) |
162
+ | `gitProvider` | `string` | `"auto"` | Активный Git-провайдер: `"auto"` (по доступным ключам), `"gitea"` или `"github"` |
163
+ | `githubTokenRef` | `string` | `""` | Имя DSH Credential с токеном доступа GitHub PAT / App |
164
+ | `githubOwner` | `string` | `""` | Организация или логин владельца репозитория GitHub |
165
+ | `githubWebhookSecretRef` | `string` | `""` | Имя DSH Credential с секретом вебхука GitHub |
162
166
  | `giteaUrl` | `string` | `""` | URL инстанса Gitea/Forgejo для синхронизации задач |
163
167
  | `giteaTokenEnv` | `string` | `"GITEA_TOKEN"` | Имя DSH Credential с токеном доступа к Gitea API |
164
168
  | `giteaOwner` | `string` | `""` | Организация или логин владельца репозитория |
@@ -170,7 +174,7 @@ dsh-kanban:
170
174
 
171
175
  ## 🧪 Тестирование и верификация
172
176
 
173
- Запуск полного набора из более чем 520 юнит- и интеграционных тестов:
177
+ Запуск полного набора из более чем 710 юнит- и интеграционных тестов:
174
178
 
175
179
  ```bash
176
180
  npm test
package/README.zh.md CHANGED
@@ -159,6 +159,10 @@ dsh-kanban:
159
159
  | 配置项 | 数据类型 | 默认值 | 功能说明 |
160
160
  |:---|:---|:---|:---|
161
161
  | `boardKind` | `string` | `"project"` | 看板模式:`"project"`(6 泳道)或 `"simple"`(4 泳道) |
162
+ | `gitProvider` | `string` | `"auto"` | 活动 Git 平台:`"auto"`(按凭证自动检测)、`"gitea"` 或 `"github"` |
163
+ | `githubTokenRef` | `string` | `""` | 包含 GitHub PAT 或应用令牌的 DSH 凭证名称 |
164
+ | `githubOwner` | `string` | `""` | 默认监听的 GitHub 组织或用户名 |
165
+ | `githubWebhookSecretRef` | `string` | `""` | 包含 GitHub Webhook 密钥的 DSH 凭证名称 |
162
166
  | `giteaUrl` | `string` | `""` | 用于同步任务的 Gitea / Forgejo 实例基础地址 |
163
167
  | `giteaTokenEnv` | `string` | `"GITEA_TOKEN"` | 存储 Gitea API Token 的 DSH 凭据键名 |
164
168
  | `giteaOwner` | `string` | `""` | 默认同步的仓库所属组织或用户名 |
package/lib/client.js CHANGED
@@ -910,7 +910,17 @@ window.__ModuleLoader__.load({
910
910
  'title': 'Kanban',
911
911
  'subtitle': 'Task board: Gitea issues and a session per task',
912
912
  'section.label': 'Kanban',
913
+ 'group.forge': 'Git Forge',
913
914
  'group.gitea': 'Gitea',
915
+ 'group.github': 'GitHub',
916
+ 'field.gitProvider': 'Git Provider',
917
+ 'hint.gitProvider': 'Active forge: "auto" (detect by credentials), "gitea", or "github".',
918
+ 'field.githubTokenRef': 'GitHub credential token',
919
+ 'hint.githubTokenRef': 'Name of DSH credential holding GitHub PAT or App token. Never type raw token.',
920
+ 'field.githubOwner': 'GitHub organization/user',
921
+ 'hint.githubOwner': 'Default GitHub account to watch. Empty uses authenticated user.',
922
+ 'field.githubWebhookSecretRef': 'GitHub webhook secret',
923
+ 'hint.githubWebhookSecretRef': 'Name of DSH credential holding GitHub webhook secret (HMAC-SHA256).',
914
924
  'group.general': 'General',
915
925
  'field.giteaUrl': 'Instance URL',
916
926
  'field.giteaTokenRef': 'Credential name',
@@ -1288,7 +1298,17 @@ window.__ModuleLoader__.load({
1288
1298
  'title': '看板',
1289
1299
  'subtitle': '任务看板:Gitea issue,每个任务一个会话',
1290
1300
  'section.label': '看板',
1301
+ 'group.forge': 'Git 平台',
1291
1302
  'group.gitea': 'Gitea',
1303
+ 'group.github': 'GitHub',
1304
+ 'field.gitProvider': 'Git 平台',
1305
+ 'hint.gitProvider': '活动 Git 平台:"auto"(自动检测)、"gitea"或"github"。',
1306
+ 'field.githubTokenRef': 'GitHub 凭据名称',
1307
+ 'hint.githubTokenRef': '存储 GitHub PAT 或应用令牌的 DSH 凭据名称。切勿在此输入真实令牌。',
1308
+ 'field.githubOwner': 'GitHub 组织或用户名',
1309
+ 'hint.githubOwner': '默认监听的 GitHub 账户。留空使用认证用户。',
1310
+ 'field.githubWebhookSecretRef': 'GitHub Webhook 密钥',
1311
+ 'hint.githubWebhookSecretRef': '存储 GitHub Webhook 密钥的 DSH 凭据名称。',
1292
1312
  'group.general': '通用',
1293
1313
  'field.giteaUrl': '实例地址',
1294
1314
  'field.giteaTokenRef': '凭据名称',
@@ -1663,8 +1683,12 @@ window.__ModuleLoader__.load({
1663
1683
 
1664
1684
 
1665
1685
  const FIELDS = [
1686
+ { field: 'gitProvider', kind: 'text', group: 'forge' },
1666
1687
  { field: 'giteaUrl', kind: 'text', group: 'gitea' },
1667
1688
  { field: 'giteaTokenRef', kind: 'text', group: 'gitea' },
1689
+ { field: 'githubTokenRef', kind: 'text', group: 'github' },
1690
+ { field: 'githubOwner', kind: 'text', group: 'github' },
1691
+ { field: 'githubWebhookSecretRef', kind: 'text', group: 'github' },
1668
1692
  { field: 'defaultProjectRoot', kind: 'text', group: 'general' },
1669
1693
  { field: 'startPrompt', kind: 'area', group: 'general' },
1670
1694
  { field: 'replyInstruction', kind: 'text', group: 'general' },
@@ -1695,6 +1719,8 @@ window.__ModuleLoader__.load({
1695
1719
  function hintKey(spec) {
1696
1720
  if (spec.group === 'sync') return 'hint.' + spec.field
1697
1721
  if (spec.group === 'gitea') return 'hint.' + spec.field
1722
+ if (spec.group === 'github') return 'hint.' + spec.field
1723
+ if (spec.group === 'forge') return 'hint.' + spec.field
1698
1724
  if (spec.group === 'labels') return 'hint.label'
1699
1725
  if (spec.group === 'limits') return 'hint.limit'
1700
1726
  return 'hint.' + spec.field
@@ -1961,6 +1987,11 @@ window.__ModuleLoader__.load({
1961
1987
  badge = h('span', {
1962
1988
  className: 'dkb-section-badge' + (hasUrl ? ' dkb-section-badge-ok' : ''),
1963
1989
  }, hasUrl ? 'Gitea Configured' : 'No URL')
1990
+ } else if (grp === 'github') {
1991
+ const hasGh = Boolean(valueOf('githubTokenRef'))
1992
+ badge = h('span', {
1993
+ className: 'dkb-section-badge' + (hasGh ? ' dkb-section-badge-ok' : ''),
1994
+ }, hasGh ? 'GitHub Configured' : 'No Token')
1964
1995
  } else if (grp === 'sync') {
1965
1996
  const interval = Number(valueOf('syncIntervalSec')) || 0
1966
1997
  badge = h('span', {
package/lib/config.js CHANGED
@@ -55,8 +55,10 @@ export const DEFAULT_BOARDS = [
55
55
 
56
56
  /** Значения по умолчанию. Из них же собирается схема в `lib/index.js`. */
57
57
  export const CONFIG_DEFAULTS = {
58
+ gitProvider: 'auto',
58
59
  giteaUrl: '',
59
60
  giteaTokenRef: 'GITEA_TOKEN',
61
+ githubTokenRef: 'GITHUB_TOKEN',
60
62
  defaultProjectRoot: '',
61
63
  startPrompt: '',
62
64
  replyInstruction: '',
@@ -65,11 +67,13 @@ export const CONFIG_DEFAULTS = {
65
67
  syncIntervalSec: 120,
66
68
  staleAfterMin: 60,
67
69
  giteaOwner: '',
70
+ githubOwner: '',
68
71
  columnNames: '',
69
72
  watchRepos: '',
70
73
  archiveAfterDays: 7,
71
74
  boardToolEnabled: false,
72
75
  webhookSecretRef: '',
76
+ githubWebhookSecretRef: '',
73
77
  pushToGitea: true,
74
78
  worktreeIsolation: true,
75
79
  worktreeRoot: '',
@@ -86,8 +90,10 @@ export const CONFIG_DEFAULTS = {
86
90
 
87
91
  /** Пояснения к полям; повторно используются схемой и карточкой настроек. */
88
92
  export const CONFIG_HINTS = {
93
+ gitProvider: 'Active Git forge: "auto" (detect by credentials), "gitea", or "github".',
89
94
  giteaUrl: 'Gitea or Forgejo instance URL, e.g. https://gitea.example.com. Empty disables import.',
90
95
  giteaTokenRef: 'Name of DSH credential holding the access token. Do not enter raw token.',
96
+ githubTokenRef: 'Name of DSH credential holding GitHub Personal Access Token or App token. Do not enter raw token.',
91
97
  defaultProjectRoot: 'Directory containing project repositories. Empty uses harness working directory.',
92
98
  startPrompt: 'Template for initial message to agent session. Empty uses built-in default.',
93
99
  replyInstruction: 'Instruction appended to initial message (e.g. language/format constraints). Empty adds nothing.',
@@ -96,11 +102,13 @@ export const CONFIG_HINTS = {
96
102
  syncIntervalSec: 'Interval in seconds for polling Gitea synchronization. 0 disables polling.',
97
103
  staleAfterMin: 'Minutes of inactivity before active task session is flagged stale. 0 disables.',
98
104
  giteaOwner: 'Default Gitea organization/user to track. Auto-detected if single organization.',
105
+ githubOwner: 'Default GitHub user or organization to track. Empty uses the authenticated user.',
99
106
  columnNames: 'Custom column title mappings as "id=Name" pairs comma-separated.',
100
107
  watchRepos: 'Filter sync to specific repository names comma-separated. Empty watches all repos.',
101
108
  archiveAfterDays: 'Days completed tasks remain before automatic archiving. 0 disables.',
102
109
  boardToolEnabled: 'Allow autonomous agent to advance cards via board_move tool.',
103
110
  webhookSecretRef: 'Name of DSH credential holding Gitea webhook secret.',
111
+ githubWebhookSecretRef: 'Name of DSH credential holding GitHub webhook secret (HMAC-SHA256).',
104
112
  pushToGitea: 'Automatically close corresponding Gitea issue when card moves to Done.',
105
113
  worktreeIsolation: 'Isolate task sessions in dedicated git worktrees to prevent workspace collisions.',
106
114
  worktreeRoot: 'Custom root directory for task worktrees. Empty defaults to $DSH_HOME/worktrees.',
@@ -0,0 +1,156 @@
1
+ // Композитный маршрутизатор Git-провайдеров (Gitea и GitHub).
2
+ //
3
+ // Предоставляет единый интерфейс к обеим системам:
4
+ // - прозрачно направляет запросы задач в соответствующий провайдер (task.provider / issueUrl);
5
+ // - в режиме 'auto' опрашивает настроенный провайдер или объединяет результаты поиска;
6
+ // - позволяет доске бесшовно работать как только с Gitea, так и только с GitHub или с обоими сразу.
7
+
8
+ export function createGitProvider({ gitea, github, getConfig }) {
9
+ async function resolveActiveProvider(hint) {
10
+ if (hint && typeof hint === 'object') {
11
+ if (hint.provider === 'github') return github
12
+ if (hint.provider === 'gitea') return gitea
13
+ if (typeof hint.issueUrl === 'string' && hint.issueUrl.includes('github.com')) {
14
+ return github
15
+ }
16
+ } else if (hint === 'github') {
17
+ return github
18
+ } else if (hint === 'gitea') {
19
+ return gitea
20
+ }
21
+
22
+ const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
23
+ if (mode === 'github') return github
24
+ if (mode === 'gitea') return gitea
25
+
26
+ // В режиме 'auto': проверяем доступность провайдеров
27
+ const giteaReady = await gitea.isConfigured()
28
+ const githubReady = await github.isConfigured()
29
+ if (githubReady && !giteaReady) return github
30
+ return gitea
31
+ }
32
+
33
+ function forTarget(target) {
34
+ if (target === github || target?.provider === 'github') return github
35
+ if (target === gitea || target?.provider === 'gitea') return gitea
36
+ return undefined
37
+ }
38
+
39
+ return {
40
+ gitea,
41
+ github,
42
+
43
+ /** Определить провайдера для задачи или контекста. */
44
+ resolve: resolveActiveProvider,
45
+
46
+ /** Настроен ли хотя бы один или конкретный провайдер. */
47
+ async isConfigured(providerName) {
48
+ if (providerName === 'github') return github.isConfigured()
49
+ if (providerName === 'gitea') return gitea.isConfigured()
50
+ const gReady = await gitea.isConfigured()
51
+ if (gReady) return true
52
+ return github.isConfigured()
53
+ },
54
+
55
+ /** Логин текущего пользователя. */
56
+ async me(target) {
57
+ const p = forTarget(target) || await resolveActiveProvider(target)
58
+ return p.me()
59
+ },
60
+
61
+ /** Список issues. */
62
+ async listIssues(options) {
63
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
64
+ return p.listIssues(options)
65
+ },
66
+
67
+ /** Получить issue по номеру. */
68
+ async getIssue(options) {
69
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
70
+ return p.getIssue(options)
71
+ },
72
+
73
+ /** Создать issue. */
74
+ async createIssue(options) {
75
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
76
+ return p.createIssue(options)
77
+ },
78
+
79
+ /** Закрыть issue. */
80
+ async closeIssue(options) {
81
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
82
+ return p.closeIssue(options)
83
+ },
84
+
85
+ /** Комментарий в issue. */
86
+ async comment(options) {
87
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
88
+ return p.comment(options)
89
+ },
90
+
91
+ /** Назначить ответственных. */
92
+ async setAssignees(options) {
93
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
94
+ return p.setAssignees(options)
95
+ },
96
+
97
+ /** Список Pull Requests. */
98
+ async listPulls(options) {
99
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
100
+ return p.listPulls(options)
101
+ },
102
+
103
+ /** Создать Pull Request. */
104
+ async createPullRequest(options) {
105
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
106
+ return p.createPullRequest(options)
107
+ },
108
+
109
+ /** Список веток репозитория. */
110
+ async listBranches(options) {
111
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
112
+ return p.listBranches(options)
113
+ },
114
+
115
+ /** Список организаций. */
116
+ async listOrgs(target) {
117
+ const p = forTarget(target) || await resolveActiveProvider(target)
118
+ return p.listOrgs()
119
+ },
120
+
121
+ /** Репозитории организации. */
122
+ async listOrgRepos(options) {
123
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
124
+ return p.listOrgRepos(options)
125
+ },
126
+
127
+ /**
128
+ * Поиск репозиториев: ищет в целевом провайдере либо агрегирует результаты
129
+ * из обоих, если включены оба.
130
+ */
131
+ async searchRepos(options) {
132
+ const target = forTarget(options?.provider)
133
+ if (target) {
134
+ return target.searchRepos(options)
135
+ }
136
+
137
+ const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
138
+ if (mode === 'github') return github.searchRepos(options)
139
+ if (mode === 'gitea') return gitea.searchRepos(options)
140
+
141
+ // Если режим 'auto' / 'both', опрашиваем оба настроенных
142
+ const [giteaConfigured, githubConfigured] = await Promise.all([
143
+ gitea.isConfigured(),
144
+ github.isConfigured(),
145
+ ])
146
+
147
+ const requests = []
148
+ if (giteaConfigured) requests.push(gitea.searchRepos(options).catch(() => []))
149
+ if (githubConfigured) requests.push(github.searchRepos(options).catch(() => []))
150
+
151
+ if (requests.length === 0) return []
152
+ const results = await Promise.all(requests)
153
+ return results.flat()
154
+ },
155
+ }
156
+ }
package/lib/github.js ADDED
@@ -0,0 +1,302 @@
1
+ // Клиент GitHub REST API v3.
2
+ //
3
+ // Выполняет те же операции, что и GiteaClient: чтение issues, pull requests,
4
+ // веток, репозиториев, создание PR и комментирование.
5
+ // Дополнительно поддерживает условные запросы с ETag (If-None-Match) для защиты
6
+ // от исчерпания часового лимита запросов GitHub (5 000 req/hr).
7
+ //
8
+ // Модуль изолирован от файловой системы и DSH: токен и конфигурация
9
+ // передаются фабрикой createGitHubClient.
10
+
11
+ const DEFAULT_BASE_URL = 'https://api.github.com'
12
+
13
+ export function normalizeGithubUrl(raw) {
14
+ const s = String(raw ?? '').trim()
15
+ if (!s) return DEFAULT_BASE_URL
16
+ const match = s.match(/^https?:\/\/[^\s/]+/i)
17
+ return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
18
+ }
19
+
20
+ export function safeSegment(value) {
21
+ const s = String(value ?? '').trim()
22
+ if (!s || s.includes('/') || s.includes('\\') || s.includes('..') || s.includes('\0')) {
23
+ return undefined
24
+ }
25
+ return s
26
+ }
27
+
28
+ export function isCredentialName(value) {
29
+ if (typeof value !== 'string') return false
30
+ const trimmed = value.trim()
31
+ return trimmed.length > 0 && !/[\s/]/.test(trimmed)
32
+ }
33
+
34
+ /**
35
+ * Создать клиент GitHub API.
36
+ *
37
+ * @param {object} options
38
+ * @param {() => object} options.getConfig функция получения конфигурации
39
+ * @param {(name: string) => Promise<string>} options.resolveToken получение токена
40
+ * @param {typeof fetch} [options.fetchImpl] реализация fetch
41
+ * @param {number} [options.timeoutMs] таймаут запроса в мс
42
+ */
43
+ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch, timeoutMs = 30_000 }) {
44
+ // Кэш условных запросов ETag: url -> { etag, data }
45
+ const etagCache = new Map()
46
+
47
+ async function request(method, path, body, { useEtag = false } = {}) {
48
+ const config = getConfig()
49
+ const baseUrl = normalizeGithubUrl(config?.githubUrl)
50
+ const tokenRef = config?.githubTokenRef || 'GITHUB_TOKEN'
51
+
52
+ const token = await resolveToken(tokenRef)
53
+ if (!token) {
54
+ throw Object.assign(new Error('GitHub credential token is not configured'), { code: 'unconfigured' })
55
+ }
56
+
57
+ const url = `${baseUrl}${path}`
58
+ const headers = {
59
+ 'Authorization': `Bearer ${token}`,
60
+ 'Accept': 'application/vnd.github+json',
61
+ 'X-GitHub-Api-Version': '2022-11-28',
62
+ 'User-Agent': 'dsh-kanban',
63
+ }
64
+
65
+ let cacheEntry
66
+ if (useEtag && method === 'GET') {
67
+ cacheEntry = etagCache.get(url)
68
+ if (cacheEntry?.etag) {
69
+ headers['If-None-Match'] = cacheEntry.etag
70
+ }
71
+ }
72
+
73
+ if (body !== undefined) {
74
+ headers['Content-Type'] = 'application/json'
75
+ }
76
+
77
+ const controller = new AbortController()
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
79
+
80
+ try {
81
+ const res = await fetchImpl(url, {
82
+ method,
83
+ headers,
84
+ body: body === undefined ? undefined : JSON.stringify(body),
85
+ signal: controller.signal,
86
+ })
87
+
88
+ if (res.status === 304 && cacheEntry) {
89
+ return cacheEntry.data
90
+ }
91
+
92
+ const text = await res.text()
93
+ let payload
94
+ try { payload = text.trim() === '' ? null : JSON.parse(text) } catch { payload = null }
95
+
96
+ if (!res.ok) {
97
+ throw Object.assign(new Error(`GitHub HTTP error ${res.status}`), {
98
+ code: 'http',
99
+ status: res.status,
100
+ detail: payload?.message,
101
+ })
102
+ }
103
+
104
+ if (useEtag && method === 'GET') {
105
+ const etag = res.headers?.get?.('etag') || res.headers?.etag
106
+ if (etag) {
107
+ etagCache.set(url, { etag, data: payload })
108
+ }
109
+ }
110
+
111
+ return payload
112
+ } finally {
113
+ clearTimeout(timer)
114
+ }
115
+ }
116
+
117
+ function repoPath(owner, repo, suffix = '') {
118
+ const o = safeSegment(owner)
119
+ const r = safeSegment(repo)
120
+ if (o === undefined || r === undefined) {
121
+ throw Object.assign(new Error('Invalid owner or repository segment'), { code: 'bad-segment' })
122
+ }
123
+ return `/repos/${encodeURIComponent(o)}/${encodeURIComponent(r)}${suffix}`
124
+ }
125
+
126
+ async function collectPages({ buildPath, limit = 50, maxPages = 10, useEtag = false }) {
127
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 100)
128
+ const out = []
129
+ for (let page = 1; page <= maxPages; page += 1) {
130
+ const rows = await request('GET', buildPath(page, size), undefined, { useEtag: useEtag && page === 1 })
131
+ if (!Array.isArray(rows)) break
132
+ out.push(...rows)
133
+ if (rows.length < size) break
134
+ }
135
+ return out
136
+ }
137
+
138
+ let configuredCache
139
+ const configuredKey = () => {
140
+ const config = getConfig()
141
+ return `${normalizeGithubUrl(config?.githubUrl)}\u0000${String(config?.githubTokenRef ?? 'GITHUB_TOKEN')}`
142
+ }
143
+
144
+ return {
145
+ provider: 'github',
146
+
147
+ /** Проверен ли токен доступа. */
148
+ async isConfigured() {
149
+ const key = configuredKey()
150
+ if (configuredCache?.key === key) return configuredCache.value
151
+ const config = getConfig()
152
+ const tokenRef = config?.githubTokenRef || 'GITHUB_TOKEN'
153
+ const value = isCredentialName(tokenRef)
154
+ && await resolveToken(tokenRef).then(Boolean).catch(() => false)
155
+ configuredCache = { key, value }
156
+ return value
157
+ },
158
+
159
+ /** Очистить кэш ETag (например, при явном ручном обновлении). */
160
+ clearCache() {
161
+ etagCache.clear()
162
+ },
163
+
164
+ /** Список issues репозитория (без PR). */
165
+ async listIssues({ owner, repo, state = 'open', limit = 50 }) {
166
+ const rows = await collectPages({
167
+ limit,
168
+ useEtag: true,
169
+ buildPath: (page, size) => repoPath(owner, repo,
170
+ `/issues?state=${encodeURIComponent(state)}&page=${page}&per_page=${size}&sort=updated&direction=desc`),
171
+ })
172
+ return rows.filter((r) => !r.pull_request)
173
+ },
174
+
175
+ /** Получить один issue по номеру. */
176
+ async getIssue({ owner, repo, index }) {
177
+ const n = safeSegment(index)
178
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
179
+ return request('GET', repoPath(owner, repo, `/issues/${n}`))
180
+ },
181
+
182
+ /** Оставить комментарий в issue. */
183
+ async comment({ owner, repo, index, body }) {
184
+ const n = safeSegment(index)
185
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
186
+ return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
187
+ },
188
+
189
+ /** Создать новый issue. */
190
+ async createIssue({ owner, repo, title, body }) {
191
+ const name = String(title ?? '').trim()
192
+ if (name === '') throw Object.assign(new Error('empty issue title'), { code: 'bad-title' })
193
+ return request('POST', repoPath(owner, repo, '/issues'), { title: name, body: String(body ?? '') })
194
+ },
195
+
196
+ /** Закрыть issue. */
197
+ async closeIssue({ owner, repo, index }) {
198
+ const n = safeSegment(index)
199
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
200
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
201
+ },
202
+
203
+ /**
204
+ * Назначить ответственных за issue.
205
+ * В GitHub REST API PATCH /issues/{n} со свойством assignees заменяет ответственных целиком.
206
+ */
207
+ async setAssignees({ owner, repo, index, logins }) {
208
+ const n = safeSegment(index)
209
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
210
+ const list = (Array.isArray(logins) ? logins : [])
211
+ .filter((one) => typeof one === 'string' && one !== '')
212
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
213
+ },
214
+
215
+ /** Логин текущего пользователя токена. */
216
+ async me() {
217
+ const row = await request('GET', '/user')
218
+ const login = row?.login
219
+ return typeof login === 'string' ? login : ''
220
+ },
221
+
222
+ /** Организации, доступные токену. */
223
+ async listOrgs() {
224
+ const rows = await request('GET', '/user/orgs?per_page=50')
225
+ return (Array.isArray(rows) ? rows : [])
226
+ .map((o) => String(o?.login ?? o?.name ?? ''))
227
+ .filter((name) => name !== '')
228
+ },
229
+
230
+ /** Репозитории организации с числом открытых задач. */
231
+ async listOrgRepos({ owner, limit = 100 }) {
232
+ const rows = await collectPages({
233
+ limit,
234
+ useEtag: true,
235
+ buildPath: (page, size) => `/orgs/${encodeURIComponent(safeSegment(owner))}/repos?page=${page}&per_page=${size}`,
236
+ })
237
+ return rows.map((r) => ({
238
+ name: String(r?.name ?? ''),
239
+ openIssues: Number(r?.open_issues_count ?? 0),
240
+ archived: r?.archived === true,
241
+ })).filter((r) => r.name !== '')
242
+ },
243
+
244
+ /** Pull requests репозитория. */
245
+ async listPulls({ owner, repo, state = 'all', limit = 50 }) {
246
+ return collectPages({
247
+ limit,
248
+ useEtag: true,
249
+ buildPath: (page, size) => repoPath(owner, repo,
250
+ `/pulls?state=${encodeURIComponent(state)}&page=${page}&per_page=${size}&sort=updated&direction=desc`),
251
+ })
252
+ },
253
+
254
+ /** Создать Pull Request. */
255
+ async createPullRequest({ owner, repo, title, body, head, base = 'main' }) {
256
+ const name = String(title ?? '').trim()
257
+ if (name === '') throw Object.assign(new Error('empty PR title'), { code: 'bad-title' })
258
+ const h = String(head ?? '').trim()
259
+ const b = String(base ?? 'main').trim()
260
+ if (!h) throw Object.assign(new Error('head branch is required'), { code: 'bad-head' })
261
+ return request('POST', repoPath(owner, repo, '/pulls'), {
262
+ title: name,
263
+ body: String(body ?? ''),
264
+ head: h,
265
+ base: b,
266
+ })
267
+ },
268
+
269
+ /** Список веток репозитория. */
270
+ async listBranches({ owner, repo, limit = 100 }) {
271
+ const rows = await collectPages({
272
+ limit,
273
+ buildPath: (page, size) => repoPath(owner, repo, `/branches?page=${page}&per_page=${size}`),
274
+ })
275
+ return rows.map((b) => ({ name: String(b?.name ?? '') })).filter((b) => b.name !== '')
276
+ },
277
+
278
+ /** Поиск репозиториев. */
279
+ async searchRepos({ query, limit = 50, maxPages = 5 }) {
280
+ const q = encodeURIComponent(String(query || ''))
281
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
282
+ const out = []
283
+ for (let page = 1; page <= maxPages; page += 1) {
284
+ const rows = await request('GET', `/search/repositories?q=${q}&per_page=${size}&page=${page}`)
285
+ const data = Array.isArray(rows?.items) ? rows.items : []
286
+ for (const r of data) {
287
+ out.push({
288
+ name: String(r?.name ?? ''),
289
+ fullName: String(r?.full_name ?? ''),
290
+ description: String(r?.description ?? ''),
291
+ openIssues: Number(r?.open_issues_count ?? 0),
292
+ updatedAt: r?.updated_at ? new Date(r.updated_at).getTime() : 0,
293
+ stars: Number(r?.stargazers_count ?? 0),
294
+ provider: 'github',
295
+ })
296
+ }
297
+ if (data.length < size) break
298
+ }
299
+ return out
300
+ },
301
+ }
302
+ }
package/lib/import.js CHANGED
@@ -69,10 +69,12 @@ export function issueMilestone(issue) {
69
69
  * Поля `branch`, `worktree`, `sessionId`, `model` НЕ заполняются: они
70
70
  * наблюдаются позже, когда работа начнётся. Плагин ветки не создаёт.
71
71
  */
72
- export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo }) {
72
+ export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider }) {
73
+ const p = provider || (typeof issue?.html_url === 'string' && issue.html_url.includes('github.com') ? 'github' : 'gitea')
73
74
  return {
74
75
  board,
75
76
  column,
77
+ provider: p,
76
78
  title: typeof issue?.title === 'string' ? issue.title : '',
77
79
  body: typeof issue?.body === 'string' ? issue.body : '',
78
80
  labels: Array.isArray(issue?.labels)
package/lib/index.js CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
59
59
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
60
60
  import { createGiteaClient } from './gitea.js'
61
+ import { createGitHubClient } from './github.js'
62
+ import { createGitProvider } from './git-provider.js'
61
63
  import { createKanbanTaskService } from './task-service.js'
62
64
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
63
65
  import { isWatched } from './intake.js'
@@ -131,7 +133,7 @@ export const Config = z.object(Object.fromEntries(
131
133
  let node = typeof fallback === 'number' ? z.number()
132
134
  : typeof fallback === 'boolean' ? z.boolean()
133
135
  : z.string()
134
- if (field === 'giteaTokenRef' || field === 'webhookSecretRef') {
136
+ if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
135
137
  node = node.role('credential-ref')
136
138
  }
137
139
  return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
@@ -249,6 +251,17 @@ export function apply(ctx, config) {
249
251
  resolveToken: resolveSecret,
250
252
  })
251
253
 
254
+ const github = createGitHubClient({
255
+ getConfig,
256
+ resolveToken: resolveSecret,
257
+ })
258
+
259
+ const git = createGitProvider({
260
+ gitea,
261
+ github,
262
+ getConfig,
263
+ })
264
+
252
265
  // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
253
266
  // выгрузка плагина обязана отпустить файл базы.
254
267
  let store
@@ -457,7 +470,7 @@ export function apply(ctx, config) {
457
470
  // Очередь отправки в Gitea. Перенос карточки не ждёт сети: доска обязана
458
471
  // работать и при недоступном Gitea, а неудачная отправка повторяется и, если
459
472
  // так и не удалась, попадает в журнал задачи.
460
- const outbox = createOutbox({ gitea, store, logger: ctx.logger })
473
+ const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
461
474
 
462
475
  const queueOutbound = (taskId, column) => {
463
476
  if (getConfig().pushToGitea !== true) return
@@ -502,10 +515,34 @@ export function apply(ctx, config) {
502
515
  // Порядок важен. Сперва забираем новое: свежая задача должна попасть на
503
516
  // доску и тут же быть сверена, а не ждать следующего прохода. Архивация
504
517
  // последней — она смотрит на итог, а не на исходное.
505
- const taken = await intakeAll({
506
- gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
507
- })
508
- const out = await syncAll({ gitea, store, logger: ctx.logger })
518
+ const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
519
+ const providersToSync = []
520
+ if (mode === 'github') {
521
+ providersToSync.push(github)
522
+ } else if (mode === 'gitea') {
523
+ providersToSync.push(gitea)
524
+ } else {
525
+ if (await gitea.isConfigured()) providersToSync.push(gitea)
526
+ if (await github.isConfigured()) providersToSync.push(github)
527
+ if (providersToSync.length === 0) providersToSync.push(gitea)
528
+ }
529
+
530
+ let taken = { added: 0, skipped: 0, failed: 0 }
531
+ for (const p of providersToSync) {
532
+ const owner = p === github
533
+ ? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
534
+ : await defaultOwner()
535
+ const res = await intakeAll({
536
+ gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
537
+ })
538
+ if (res && typeof res.added === 'number') {
539
+ taken.added = (taken.added || 0) + res.added
540
+ taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
541
+ taken.failed = (taken.failed || 0) + (res.failed || 0)
542
+ }
543
+ if (res?.problem) taken.problem = res.problem
544
+ }
545
+ const out = await syncAll({ gitea: git, store, logger: ctx.logger })
509
546
  const tidied = archiveOverdue({ store, config: getConfig() })
510
547
  // Отложенные отправки уходят той же волной: отдельный таймер ради них
511
548
  // означал бы второй источник обращений к Gitea без всякой пользы.
@@ -550,7 +587,7 @@ export function apply(ctx, config) {
550
587
  try {
551
588
  const input = await readBody(req)
552
589
  reply(res, await createProjectTask({
553
- gitea, store,
590
+ gitea: git, store,
554
591
  owner: input.owner || await defaultOwner(),
555
592
  repo: input.repo, newRepo: input.newRepo,
556
593
  title: input.title, body: input.body,
@@ -666,10 +703,13 @@ export function apply(ctx, config) {
666
703
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
667
704
  try {
668
705
  const raw = await readRaw(req)
669
- const secret = await resolveSecret(getConfig().webhookSecretRef)
670
- // Без секрета вебхук не принимается вовсе: иначе карточки двигал бы
671
- // кто угодно снаружи.
672
- if (!verifySignature(secret, raw, req.headers['x-gitea-signature'])) {
706
+ const isGithub = Boolean(req.headers['x-github-event'] || req.headers['x-hub-signature-256'])
707
+ const secretRef = isGithub
708
+ ? (getConfig().githubWebhookSecretRef || getConfig().webhookSecretRef)
709
+ : getConfig().webhookSecretRef
710
+ const secret = await resolveSecret(secretRef)
711
+ const sig = isGithub ? req.headers['x-hub-signature-256'] : req.headers['x-gitea-signature']
712
+ if (!verifySignature(secret, raw, sig)) {
673
713
  writeJson(res, 401, { error: 'bad-signature' })
674
714
  return
675
715
  }
@@ -678,29 +718,21 @@ export function apply(ctx, config) {
678
718
  const target = parseEvent(payload)
679
719
  if (target === undefined) { writeJson(res, 202, { ignored: true }); return }
680
720
 
681
- // Сперва подхват, потом сверка — тот же порядок, что и по таймеру:
682
- // новый issue должен стать карточкой и тут же быть сверен. Без этого
683
- // он ждал бы таймера, хотя вебхук про него уже знает, а вебхук мы и
684
- // заводили ради двух секунд вместо ста двадцати.
685
- //
686
- // Только по репозиторию события: полный обход при каждом чихе в любом
687
- // из отслеживаемых означал бы обращение ко всем.
688
- // Событие всегда называет свой репозиторий, и владельца выяснять не
689
- // надо: подхватываем его напрямую, если он вообще под наблюдением.
690
- const owner = await defaultOwner()
721
+ const activeGit = isGithub ? github : gitea
722
+ const owner = isGithub
723
+ ? (String(getConfig().githubOwner ?? '').trim() || target.owner)
724
+ : await defaultOwner()
691
725
  const taken = target.owner === owner || isWatched({
692
726
  config: getConfig(), owner: target.owner, defaultOwner: owner, repo: target.repo,
693
727
  })
694
728
  ? await intakeAll({
695
- gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
729
+ gitea: activeGit, store, config: getConfig(), owner, logger: ctx.logger,
696
730
  only: (pair) => pair.owner === target.owner && pair.repo === target.repo,
697
731
  })
698
732
  : { added: 0, skipped: 0, failed: 0 }
699
733
 
700
- // Вебхук говорит «здесь что-то произошло», а решает сверка: у неё перед
701
- // глазами полная картина, а у события — только его кусок.
702
734
  const out = await syncAll({
703
- gitea, store, logger: ctx.logger,
735
+ gitea: git, store, logger: ctx.logger,
704
736
  only: (t) => t.owner === target.owner && t.repo === target.repo
705
737
  && (target.issueNumber === undefined || t.issueNumber === target.issueNumber),
706
738
  })
@@ -733,7 +765,7 @@ export function apply(ctx, config) {
733
765
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
734
766
  ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
735
767
  ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
736
- ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea }))),
768
+ ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
737
769
  ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
738
770
  ]
739
771
  return () => { for (const stop of off) stop?.() }
@@ -929,7 +961,7 @@ export function apply(ctx, config) {
929
961
  return
930
962
  }
931
963
  if (req.method === 'POST' && action === 'refresh') {
932
- reply(res, await refreshTask({ gitea, store, id }))
964
+ reply(res, await refreshTask({ gitea: git, store, id }))
933
965
  return
934
966
  }
935
967
  if (req.method === 'POST' && action === 'start') {
@@ -1051,12 +1083,12 @@ export function apply(ctx, config) {
1051
1083
  return
1052
1084
  }
1053
1085
  if (req.method === 'POST' && action === 'triage') {
1054
- const resObj = await handleTriageTask({ store, gitea, task: store.getTask(id), input })
1086
+ const resObj = await handleTriageTask({ store, gitea: git, task: store.getTask(id), input })
1055
1087
  writeJson(res, resObj.status, resObj.body)
1056
1088
  return
1057
1089
  }
1058
1090
  if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
1059
- const resObj = await handleCreateSubtask({ store, gitea, task: store.getTask(id), input })
1091
+ const resObj = await handleCreateSubtask({ store, gitea: git, task: store.getTask(id), input })
1060
1092
  writeJson(res, resObj.status, resObj.body)
1061
1093
  return
1062
1094
  }
@@ -1066,7 +1098,7 @@ export function apply(ctx, config) {
1066
1098
  return
1067
1099
  }
1068
1100
  if (req.method === 'POST' && action === 'pr') {
1069
- const resObj = await handleCreateTaskPr({ store, gitea, task: store.getTask(id), input })
1101
+ const resObj = await handleCreateTaskPr({ store, gitea: git, task: store.getTask(id), input })
1070
1102
  writeJson(res, resObj.status, resObj.body)
1071
1103
  return
1072
1104
  }
@@ -1223,7 +1255,7 @@ export function apply(ctx, config) {
1223
1255
  path: '/dsh-kanban/gitea/repos',
1224
1256
  handler: async (req, res) => {
1225
1257
  const url = urlOf(req)
1226
- reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
1258
+ reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1227
1259
  },
1228
1260
  }), 'dsh-kanban: repo search')
1229
1261
 
@@ -1233,7 +1265,7 @@ export function apply(ctx, config) {
1233
1265
  handler: async (req, res) => {
1234
1266
  const url = urlOf(req)
1235
1267
  reply(res, await listImportable({
1236
- gitea,
1268
+ gitea: git,
1237
1269
  store,
1238
1270
  owner: url.searchParams.get('owner') || undefined,
1239
1271
  repo: url.searchParams.get('repo') || undefined,
package/lib/store.js CHANGED
@@ -106,7 +106,7 @@ const WRITABLE = [
106
106
  ]
107
107
 
108
108
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
109
- const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
109
+ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool'])
110
110
 
111
111
  function rowToTask(row) {
112
112
  if (row === undefined) return undefined
@@ -205,6 +205,7 @@ export function openStore({ dir }) {
205
205
  ['parentId', "TEXT NOT NULL DEFAULT ''"],
206
206
  ['isEpic', "INTEGER NOT NULL DEFAULT 0"],
207
207
  ['externalRef', "TEXT"],
208
+ ['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
208
209
  ]) {
209
210
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
210
211
  }
package/lib/webhook.js CHANGED
@@ -1,4 +1,4 @@
1
- // Приём событий Gitea.
1
+ // Приём событий Gitea и GitHub.
2
2
  //
3
3
  // Вебхук даёт мгновенную сверку вместо ожидания следующего опроса. Опрос при
4
4
  // этом остаётся: вебхук может быть не настроен, недоступен снаружи или просто
@@ -9,7 +9,7 @@
9
9
  import { createHmac, timingSafeEqual } from 'node:crypto'
10
10
 
11
11
  /**
12
- * Проверить подпись доставки.
12
+ * Проверить подпись доставки (поддерживает Gitea X-Gitea-Signature и GitHub X-Hub-Signature-256).
13
13
  *
14
14
  * Сравнение постоянного времени: обычное посимвольное сравнение по времени
15
15
  * ответа выдаёт, сколько первых байтов угадано, и подпись подбирается за
@@ -17,16 +17,33 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
17
17
  *
18
18
  * @param {string} secret общий секрет
19
19
  * @param {string|Buffer} body сырое тело запроса
20
- * @param {string} signature значение заголовка X-Gitea-Signature
20
+ * @param {string} signature значение заголовка X-Gitea-Signature или X-Hub-Signature-256
21
21
  */
22
22
  export function verifySignature(secret, body, signature) {
23
23
  if (!secret) return false
24
- const provided = String(signature ?? '').trim().toLowerCase()
24
+ let provided = String(signature ?? '').trim().toLowerCase()
25
+ if (provided.startsWith('sha256=')) {
26
+ provided = provided.slice(7).trim()
27
+ }
25
28
  if (!/^[0-9a-f]{64}$/.test(provided)) return false
26
29
  const expected = createHmac('sha256', secret).update(body ?? '').digest('hex')
27
30
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'))
28
31
  }
29
32
 
33
+ /**
34
+ * Определить провайдера вебхука по входящим заголовкам запроса.
35
+ *
36
+ * @param {object} headers заголовки HTTP-запроса
37
+ * @returns {'github'|'gitea'}
38
+ */
39
+ export function detectWebhookProvider(headers) {
40
+ if (!headers || typeof headers !== 'object') return 'gitea'
41
+ if (headers['x-github-event'] || headers['x-hub-signature-256']) {
42
+ return 'github'
43
+ }
44
+ return 'gitea'
45
+ }
46
+
30
47
  /**
31
48
  * Что за задачу затронуло событие.
32
49
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.14",
4
- "description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
3
+ "version": "0.2.15",
4
+ "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "dsh-plugin",
28
28
  "kanban",
29
29
  "gitea",
30
+ "github",
30
31
  "tasks",
31
32
  "agents",
32
33
  "deepseek-harness"