@asc-agent/runtime 0.7.1 → 0.8.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.
@@ -12,6 +12,7 @@
12
12
  // MR 생성과 같은 종류의 행위다. Core 는 여전히 어느 것도 해석하지 않는다.
13
13
  import { execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
+ import { normalize } from "../../core/execution/remote-review.js";
15
16
  import { encodeProject, parseRef } from "./client.js";
16
17
  const run = promisify(execFile);
17
18
  /** 이 adapter 가 수행할 수 있는 행위. 목록에 없는 것은 하지 않는다. */
@@ -26,6 +27,19 @@ export const GITLAB_ACTIONS = [
26
27
  'gitlab.issue.update',
27
28
  'git.push',
28
29
  ];
30
+ /**
31
+ * 되돌려 읽을 수 있는 행위 (0.8.0 보정 P1-2).
32
+ *
33
+ * `coordination.publish` 가 여기 없는 이유는 그 실행이 이 통로가 아니라 조율 표면에서
34
+ * 나가기 때문이다 — 자기가 하지 않은 일을 확인했다고 말하지 않는다.
35
+ */
36
+ export const VERIFIABLE_ACTIONS = [
37
+ 'git.push',
38
+ 'gitlab.mr.create',
39
+ 'gitlab.mr.merge',
40
+ 'gitlab.note.create',
41
+ 'gitlab.issue.update',
42
+ ];
29
43
  export class GitLabScm {
30
44
  id = 'gitlab';
31
45
  #reader;
@@ -33,6 +47,7 @@ export class GitLabScm {
33
47
  #project;
34
48
  #sourceRefs;
35
49
  #repoRoot;
50
+ #remote;
36
51
  #git;
37
52
  constructor(deps) {
38
53
  this.#reader = deps.reader;
@@ -40,6 +55,7 @@ export class GitLabScm {
40
55
  this.#project = deps.defaultProject;
41
56
  this.#sourceRefs = deps.sourceRefs ?? {};
42
57
  this.#repoRoot = deps.repoRoot;
58
+ this.#remote = deps.remoteName ?? 'origin';
43
59
  this.#git =
44
60
  deps.git ??
45
61
  (async (args, cwd) => {
@@ -93,6 +109,306 @@ export class GitLabScm {
93
109
  supports(action) {
94
110
  return GITLAB_ACTIONS.includes(action);
95
111
  }
112
+ /**
113
+ * 나가기 **전에** 읽히는 사실 (0.8.0 §D·§L·§M·§N). 판정은 하지 않는다 — Core 의
114
+ * Remote Review 가 이 사실로 판정한다. 여기서 쓰는 것은 하나도 없다.
115
+ */
116
+ async review(action) {
117
+ return { verifiable: this.verifies(action.action), ...(await this.#review(action)) };
118
+ }
119
+ async #review(action) {
120
+ const capability = this.supports(action.action);
121
+ switch (action.action) {
122
+ case 'git.push':
123
+ return this.#reviewPush(action, capability);
124
+ case 'gitlab.mr.create':
125
+ return this.#reviewCreateChange(action, capability);
126
+ case 'gitlab.mr.merge':
127
+ return this.#reviewMergeChange(action, capability);
128
+ case 'gitlab.note.create': {
129
+ const ref = parseRef(this.#expand(action.target));
130
+ if (!ref) {
131
+ return { provider: this.id, capability: false, target: action.target, unknown: ['unrecognized target'] };
132
+ }
133
+ return {
134
+ provider: this.id,
135
+ capability,
136
+ resource: ref.project,
137
+ target: action.target,
138
+ // 성공했다면 밖에 이 본문이 있어야 한다 — 실행 전에 적어 둔다.
139
+ observed: { 'expect.body': action.payload },
140
+ };
141
+ }
142
+ case 'gitlab.issue.update': {
143
+ const ref = parseRef(this.#expand(action.target));
144
+ if (!ref || ref.kind !== 'issue') {
145
+ return { provider: this.id, capability: false, target: action.target, unknown: ['unrecognized issue'] };
146
+ }
147
+ const expected = {};
148
+ try {
149
+ for (const [field, value] of Object.entries(JSON.parse(action.payload))) {
150
+ expected[`expect.issue.${field}`] = String(value);
151
+ }
152
+ }
153
+ catch {
154
+ return {
155
+ provider: this.id,
156
+ capability,
157
+ resource: ref.project,
158
+ target: action.target,
159
+ unknown: ['payload is not JSON'],
160
+ };
161
+ }
162
+ return { provider: this.id, capability, resource: ref.project, target: action.target, observed: expected };
163
+ }
164
+ default: {
165
+ const ref = parseRef(this.#expand(action.target));
166
+ return {
167
+ provider: this.id,
168
+ capability,
169
+ target: action.target,
170
+ ...(ref ? { resource: ref.project } : this.#project ? { resource: this.#project } : {}),
171
+ };
172
+ }
173
+ }
174
+ }
175
+ /** 되돌려 읽을 수 있는 행위. `execute` 의 분기와 이 목록이 갈리면 그것이 결함이다. */
176
+ verifies(action) {
177
+ return VERIFIABLE_ACTIONS.includes(action);
178
+ }
179
+ /**
180
+ * 나간 **뒤에** 밖에서 읽히는 사실. 명령이 0 으로 끝났다는 것은 성공이 아니다.
181
+ */
182
+ async verify(action, result) {
183
+ switch (action.action) {
184
+ case 'git.push': {
185
+ if (!this.#repoRoot)
186
+ return { observed: {}, unsupported: true };
187
+ const [remote, branch] = this.#pushTarget(action.target);
188
+ const listed = await this.#git(['ls-remote', remote, `refs/heads/${branch}`], this.#repoRoot);
189
+ return { observed: { sha: listed.ok ? listed.detail.split(/\s+/)[0] : undefined, target: `${remote}/${branch}` } };
190
+ }
191
+ case 'gitlab.mr.create': {
192
+ const iid = /!(\d+)/.exec(result.resultRef)?.[1];
193
+ const project = this.#projectOf(action.target);
194
+ if (!iid || !project)
195
+ return { observed: {}, unsupported: true };
196
+ const mr = await this.#change(project, Number(iid));
197
+ return {
198
+ observed: {
199
+ resource: project,
200
+ sha: mr?.sha,
201
+ source: mr?.source_branch,
202
+ target: mr?.target_branch,
203
+ title: mr?.title,
204
+ state: mr?.state,
205
+ },
206
+ };
207
+ }
208
+ case 'gitlab.mr.merge': {
209
+ const ref = parseRef(this.#expand(action.target));
210
+ if (!ref || ref.kind !== 'change')
211
+ return { observed: {}, unsupported: true };
212
+ const mr = await this.#change(ref.project, ref.iid);
213
+ return {
214
+ observed: {
215
+ resource: ref.project,
216
+ state: mr?.state,
217
+ sha: mr?.sha,
218
+ merge_commit: mr?.merge_commit_sha ?? mr?.squash_commit_sha,
219
+ },
220
+ };
221
+ }
222
+ case 'gitlab.note.create': {
223
+ // 글이 실제로 그 자리에 있는가. id 는 방금 만든 것의 것이고, 본문은 사람이 승인한
224
+ // 그대로여야 한다 — 다른 것이 올라갔다면 성공이 아니다.
225
+ const ref = parseRef(this.#expand(action.target));
226
+ const noteId = /#note_(\d+)$/.exec(result.resultRef)?.[1];
227
+ if (!ref || !noteId)
228
+ return { observed: {}, unsupported: true };
229
+ const path = ref.kind === 'change' ? 'merge_requests' : 'issues';
230
+ const note = await this.#reader.get(`/projects/${encodeProject(ref.project)}/${path}/${ref.iid}/notes/${noteId}`);
231
+ return {
232
+ observed: {
233
+ resource: ref.project,
234
+ note: note.ok && note.data ? String(note.data.id) : undefined,
235
+ body: note.ok ? note.data?.body : undefined,
236
+ },
237
+ };
238
+ }
239
+ case 'gitlab.issue.update': {
240
+ const ref = parseRef(this.#expand(action.target));
241
+ if (!ref || ref.kind !== 'issue')
242
+ return { observed: {}, unsupported: true };
243
+ const issue = await this.#reader.get(`/projects/${encodeProject(ref.project)}/issues/${ref.iid}`);
244
+ if (!issue.ok || !issue.data)
245
+ return { observed: { resource: ref.project } };
246
+ // 승인된 payload 의 필드가 실제로 그 값이 됐는가. 필드는 payload 가 정한다 —
247
+ // adapter 가 무엇을 볼지 고르지 않는다.
248
+ const observed = { resource: ref.project };
249
+ try {
250
+ for (const [field, value] of Object.entries(JSON.parse(action.payload))) {
251
+ const current = issue.data[field];
252
+ observed[`issue.${field}`] = current === undefined ? undefined : String(current);
253
+ void value;
254
+ }
255
+ }
256
+ catch {
257
+ return { observed, unsupported: true };
258
+ }
259
+ return { observed };
260
+ }
261
+ default:
262
+ return { observed: {}, unsupported: true };
263
+ }
264
+ }
265
+ /** 올릴 가지의 지금 상태 — 이름이 아니라 SHA 로 본다 (§L). */
266
+ async #reviewPush(action, capability) {
267
+ if (!this.#repoRoot) {
268
+ return { provider: this.id, capability: false, target: action.target, unknown: ['no repository root for git.push'] };
269
+ }
270
+ const [remote, branch] = this.#pushTarget(action.target);
271
+ const unknown = [];
272
+ const url = await this.#git(['remote', 'get-url', remote], this.#repoRoot);
273
+ if (!url.ok)
274
+ unknown.push(`remote url for ${remote}`);
275
+ const head = await this.#git(['rev-parse', 'HEAD'], this.#repoRoot);
276
+ if (!head.ok)
277
+ unknown.push('local HEAD');
278
+ const listed = await this.#git(['ls-remote', remote, `refs/heads/${branch}`], this.#repoRoot);
279
+ if (!listed.ok)
280
+ unknown.push(`remote ref ${branch}`);
281
+ const remoteSha = listed.ok ? (listed.detail.split(/\s+/)[0] ?? '') : '';
282
+ // 되감기가 필요한 상태인가. 조상이 아니면 이 push 는 남의 것을 덮는 형태가 된다.
283
+ let divergence;
284
+ if (head.ok && remoteSha) {
285
+ const ancestor = await this.#git(['merge-base', '--is-ancestor', remoteSha, head.detail], this.#repoRoot);
286
+ divergence = ancestor.ok ? 'fast-forward' : 'diverged';
287
+ }
288
+ else if (head.ok && listed.ok && !remoteSha) {
289
+ divergence = 'new-branch';
290
+ }
291
+ return {
292
+ provider: this.id,
293
+ capability,
294
+ target: `${remote}/${branch}`,
295
+ ...(url.ok ? { resource: identityOf(url.detail) } : {}),
296
+ observed: {
297
+ 'remote.name': remote,
298
+ 'remote.url': url.ok ? url.detail : undefined,
299
+ 'remote.sha': remoteSha || undefined,
300
+ 'local.head': head.ok ? head.detail : undefined,
301
+ branch,
302
+ ...(divergence ? { divergence } : {}),
303
+ },
304
+ ...(divergence === 'diverged'
305
+ ? { ambiguity: [`${remote}/${branch} is not an ancestor of this HEAD — this push would not fast-forward`] }
306
+ : {}),
307
+ ...(unknown.length > 0 ? { unknown } : {}),
308
+ };
309
+ }
310
+ /** 만들려는 변경요청의 자리 — 같은 것이 이미 있으면 그것이 모호함이다 (§M). */
311
+ async #reviewCreateChange(action, capability) {
312
+ const project = this.#projectOf(action.target);
313
+ if (!project)
314
+ return { provider: this.id, capability: false, unknown: ['no project for gitlab.mr.create'] };
315
+ let body = {};
316
+ const unknown = [];
317
+ try {
318
+ body = JSON.parse(action.payload);
319
+ }
320
+ catch {
321
+ unknown.push('payload is not JSON');
322
+ }
323
+ const source = typeof body['source_branch'] === 'string' ? body['source_branch'] : undefined;
324
+ const target = typeof body['target_branch'] === 'string' ? body['target_branch'] : undefined;
325
+ const ambiguity = [];
326
+ if (source) {
327
+ const open = await this.#reader.get(`/projects/${encodeProject(project)}/merge_requests?state=opened&source_branch=${encodeURIComponent(source)}`);
328
+ if (!open.ok)
329
+ unknown.push('open merge requests for this source branch');
330
+ for (const existing of open.data ?? []) {
331
+ ambiguity.push(`!${existing.iid} is already open from ${source} into ${existing.target_branch ?? '(unknown)'}`);
332
+ }
333
+ }
334
+ else {
335
+ unknown.push('source_branch');
336
+ }
337
+ if (!target)
338
+ unknown.push('target_branch');
339
+ const sha = source ? await this.#branchSha(project, source) : undefined;
340
+ const baseline = target ? await this.#branchSha(project, target) : undefined;
341
+ return {
342
+ provider: this.id,
343
+ capability,
344
+ resource: project,
345
+ target: action.target,
346
+ observed: {
347
+ source,
348
+ target,
349
+ 'local.head': sha,
350
+ 'remote.sha': baseline,
351
+ title: typeof body['title'] === 'string' ? body['title'] : undefined,
352
+ },
353
+ ...(ambiguity.length > 0 ? { ambiguity } : {}),
354
+ ...(unknown.length > 0 ? { unknown } : {}),
355
+ };
356
+ }
357
+ /** 합치려는 변경요청의 지금 — 무엇을 어디로, 어느 SHA 에서 (§N). */
358
+ async #reviewMergeChange(action, capability) {
359
+ const ref = parseRef(this.#expand(action.target));
360
+ if (!ref || ref.kind !== 'change') {
361
+ return { provider: this.id, capability: false, target: action.target, unknown: ['unrecognized change reference'] };
362
+ }
363
+ const mr = await this.#change(ref.project, ref.iid);
364
+ if (!mr) {
365
+ return { provider: this.id, capability, resource: ref.project, target: action.target, unknown: ['the merge request'] };
366
+ }
367
+ const ambiguity = [];
368
+ if (mr.state !== 'opened')
369
+ ambiguity.push(`!${ref.iid} is ${mr.state ?? '(unknown state)'}, not open`);
370
+ if (mr.merge_status && mr.merge_status !== 'can_be_merged')
371
+ ambiguity.push(`merge status is ${mr.merge_status}`);
372
+ if (mr.has_conflicts)
373
+ ambiguity.push('the merge request reports conflicts');
374
+ if (mr.draft)
375
+ ambiguity.push('the merge request is a draft');
376
+ return {
377
+ provider: this.id,
378
+ capability,
379
+ resource: ref.project,
380
+ target: action.target,
381
+ observed: {
382
+ state: mr.state,
383
+ source: mr.source_branch,
384
+ target: mr.target_branch,
385
+ 'local.head': mr.sha,
386
+ merge_status: mr.merge_status,
387
+ pipeline: mr.pipeline?.status,
388
+ },
389
+ ...(ambiguity.length > 0 ? { ambiguity } : {}),
390
+ };
391
+ }
392
+ async #change(project, iid) {
393
+ const response = await this.#reader.get(`/projects/${encodeProject(project)}/merge_requests/${iid}`);
394
+ return response.ok ? (response.data ?? null) : null;
395
+ }
396
+ async #branchSha(project, branch) {
397
+ const response = await this.#reader.get(`/projects/${encodeProject(project)}/repository/branches/${encodeURIComponent(branch)}`);
398
+ return response.ok ? response.data?.commit?.id : undefined;
399
+ }
400
+ #projectOf(target) {
401
+ if (!target.trim())
402
+ return this.#project;
403
+ const ref = parseRef(this.#expand(target));
404
+ return ref?.project ?? (target.includes('/') ? target.trim() : this.#project);
405
+ }
406
+ #pushTarget(target) {
407
+ const parts = target.trim().split(/\s+/).filter(Boolean);
408
+ if (parts.length === 0)
409
+ return [this.#remote, ''];
410
+ return parts.length === 1 ? [this.#remote, parts[0]] : [parts[0], parts[1]];
411
+ }
96
412
  async execute(action) {
97
413
  switch (action.action) {
98
414
  case 'gitlab.note.create':
@@ -151,9 +467,12 @@ export class GitLabScm {
151
467
  const ref = parseRef(this.#expand(action.target));
152
468
  if (!ref || ref.kind !== 'change')
153
469
  return { ok: false, error: `unrecognized change: ${action.target}` };
154
- // GitLab 의 merge 는 PUT 이다. adapter 통로는 post 하나이므로, 통로가 넓어지기
155
- // 전까지는 없다고 **말한다** 하는 것을 하는 척하지 않는다.
156
- const response = await this.#writer.post(`/projects/${encodeProject(ref.project)}/merge_requests/${ref.iid}/merge`, action.payload ? JSON.parse(action.payload) : {});
470
+ // GitLab 의 merge 는 `PUT /merge_requests/:iid/merge` 다. 예전에 POST 보낸 것은
471
+ // 계약 위반이었고, 실패는 승인이 끝난 **뒤에** 났다.
472
+ if (!this.#writer.put) {
473
+ return { ok: false, error: 'this write channel cannot send PUT — gitlab.mr.merge needs it' };
474
+ }
475
+ const response = await this.#writer.put(`/projects/${encodeProject(ref.project)}/merge_requests/${ref.iid}/merge`, action.payload ? JSON.parse(action.payload) : {});
157
476
  if (!response.ok || !response.data)
158
477
  return { ok: false, error: response.error ?? `HTTP ${response.status}` };
159
478
  return { ok: true, resultRef: response.data.web_url ?? `${ref.project}!${ref.iid}` };
@@ -187,10 +506,16 @@ export class GitLabScm {
187
506
  return { ok: false, error: 'git.push needs a branch' };
188
507
  if (parts.some((part) => part.startsWith('-')))
189
508
  return { ok: false, error: `git.push takes no flags: ${action.target}` };
190
- const [remote, branch] = parts.length === 1 ? ['origin', parts[0]] : [parts[0], parts[1]];
191
- const result = await this.#git(['push', remote, branch], this.#repoRoot);
509
+ const [remote, branch] = parts.length === 1 ? [this.#remote, parts[0]] : [parts[0], parts[1]];
510
+ // **가지 이름이 아니라 commit 을 올린다** (§L). 승인은 SHA 에 대한 것이었고, 그
511
+ // 사이에 HEAD 가 움직였다면 같은 명령이 다른 내용을 내보낸다. HEAD 를 못 읽으면
512
+ // 이름으로 밀지 않고 그 사실을 말한다.
513
+ const head = await this.#git(['rev-parse', 'HEAD'], this.#repoRoot);
514
+ if (!head.ok)
515
+ return { ok: false, error: `could not read HEAD: ${head.detail}` };
516
+ const result = await this.#git(['push', remote, `${head.detail}:refs/heads/${branch}`], this.#repoRoot);
192
517
  return result.ok
193
- ? { ok: true, resultRef: `${remote}/${branch}` }
518
+ ? { ok: true, resultRef: `${remote}/${branch}@${head.detail}` }
194
519
  : { ok: false, error: result.detail };
195
520
  }
196
521
  #expand(reference) {
@@ -199,3 +524,13 @@ export class GitLabScm {
199
524
  return /^[!#]\d+$/.test(reference.trim()) ? `${this.#project}${reference.trim()}` : reference;
200
525
  }
201
526
  }
527
+ /**
528
+ * 원격 URL 에서 프로젝트 신원만 꺼낸다 — `git@host:group/p.git` 도 `https://host/group/p` 도
529
+ * 같은 `group/p` 다. 결합과 견주는 값이므로 형태가 아니라 신원이어야 한다.
530
+ */
531
+ export function identityOf(url) {
532
+ const trimmed = url.trim();
533
+ const ssh = /^[^@\s]+@[^:]+:(.+)$/.exec(trimmed);
534
+ const path = ssh ? ssh[1] : trimmed.replace(/^[a-z+]+:\/\/[^/]+\//i, '');
535
+ return normalize(path);
536
+ }