toolroll 0.9.3 → 0.9.5

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.
@@ -4,9 +4,11 @@ import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
4
4
  import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
5
5
  import { authenticateCoordinator } from './coordinator.js';
6
6
  import { reproveApprover } from './principal.js';
7
+ import { authenticateApprover } from './scope.js';
7
8
  import { requestResultChanges } from './result-actions.js';
8
9
  import { commentSourceKey, revisionSourceOf } from './result-review.js';
9
10
  import { envelopeJson } from './envelope.js';
11
+ import { completeAndOpenPullRequest, mergeAsPerson, newestPullRequestOf, publishingOf, pullRequestViewOf } from './pull-request-flow.js';
10
12
  export async function runTaskOutcomeCommand(action, positional, flags, context) {
11
13
  const command = `task ${action}`;
12
14
  const emit = (result, lines) => {
@@ -14,18 +16,21 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
14
16
  return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
15
17
  };
16
18
  const fail = (reason, message) => emit({ ok: false, reason, message });
17
- const allowed = new Set(['json', 'db', 'as', 'token', 'token-env', 'token-file', ...(action === 'complete' ? ['digest'] : ['feedback', 'run', 'source', 'key'])]);
19
+ const allowed = new Set(['json', 'db', 'as', 'token', 'token-env', 'token-file', ...(action === 'complete' ? ['digest', 'pull-request'] : ['feedback', 'run', 'source', 'key'])]);
18
20
  for (const key of flags.keys())
19
21
  if (!allowed.has(key))
20
22
  return fail('usage', `--${key} is not a task ${action} option.`);
21
23
  const task = positional[0];
22
24
  if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
23
- return fail('usage', `Use task ${action} <task>${action === 'complete' ? ' --digest <receipt>' : ' --feedback "requested change"'}.`);
25
+ return fail('usage', `Use task ${action} <task>${action === 'complete' ? ' --digest <receipt> [--pull-request]' : ' --feedback "requested change"'}.`);
26
+ const pullRequest = flags.has('pull-request');
24
27
  const credential = coordinatorToken(flags, process.env);
25
28
  if ('ok' in credential)
26
29
  return emit(credential);
27
30
  if (credential.token !== null && (flags.has('as') || flags.has('token')))
28
31
  return fail('usage', 'Choose a scoped credential or your operator sign-in.');
32
+ if (credential.token !== null && pullRequest)
33
+ return fail('unauthenticated', 'Opening a pull request is an approver\'s act. Use your operator sign-in (--as and --token), not a scoped credential.');
29
34
  const operator = credential.token === null ? await context.operator() : null;
30
35
  if (credential.token === null && operator === null)
31
36
  return fail('unauthenticated', 'Sign in with up, or supply --as and --token. Agents can use --token-file or --token-env with a scoped credential.');
@@ -49,6 +54,28 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
49
54
  const digest = typeof supplied === 'string' ? supplied : current.receipt?.digest;
50
55
  if (!digest)
51
56
  return fail('not-ready', 'This task has no finished result to mark complete.');
57
+ if (pullRequest) {
58
+ // The console's "Complete and open a pull request": the same exact-receipt completion and owed PR, one transaction.
59
+ const repo = store.lookupRef(current.activeTaskId)?.repo ?? null;
60
+ const publishing = publishingOf(store, repo);
61
+ if (!publishing.on)
62
+ return fail('pull-requests-off', `Pull requests aren't set up for this project. Turn them on with: toolroll publish setup --repo ${repo ?? '<path>'} --yes`);
63
+ if (publishing.legacy)
64
+ return fail('pull-requests-off', 'This project opens a pull request for every build. Complete without --pull-request.');
65
+ const runId = current.receipt?.runId;
66
+ if (runId === undefined)
67
+ return fail('not-ready', 'This task has no finished result to mark complete.');
68
+ const opened = completeAndOpenPullRequest(store, { taskId: task, digest, runId, who: operator, root: evidenceRoot }, now);
69
+ if (!opened.ok)
70
+ return emit(opened);
71
+ const link = opened.publication.prUrl;
72
+ return emit({ ok: true, result: assignmentOf(store, task, now, { principal: 'operator', repos }, evidenceRoot), pullRequest: pullRequestViewOf(store, runId) }, [
73
+ `${current.rootId} · Complete`,
74
+ `Result: ${current.activeTaskId} · run ${runId} · ${opened.publication.headSha}`,
75
+ link === null ? `Pull request: opening from ${opened.publication.headSha.slice(0, 12)} into ${publishing.githubRepo} ${publishing.base}. toolroll task show ${current.rootId} prints its link.` : `Pull request: ${link}`,
76
+ `Next: CI is watched on GitHub. Merge when green: toolroll task merge ${current.rootId}`,
77
+ ]);
78
+ }
52
79
  const changed = credential.token !== null
53
80
  ? assignmentForCoordinator(store, credential.token, 'check', { ref: task, digest }, now, evidenceRoot)
54
81
  : checkAssignmentAsOperator(store, task, digest, operator, now, evidenceRoot);
@@ -90,3 +117,49 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
90
117
  return emit({ ok: true, result: { id: revised.id, rootId: current.rootId, sourceRun: run.id, source, key, approved, planning } }, [`${current.rootId} · Revision created`, `Execution: ${revised.id}`, approved ? 'Covered by your existing operating mode.' : planning ? 'Updating the plan with your notes; approve it when it is ready.' : 'The revision is waiting for approval.']);
91
118
  });
92
119
  }
120
+ /** `task merge <task>`: the console's Merge — the newest pull request any version of the task opened through
121
+ * Complete, merged behind the approver's password once a fresh read shows its checks passing. */
122
+ export async function runTaskMergeCommand(positional, flags, context) {
123
+ const command = 'task merge';
124
+ const emit = (result, lines) => {
125
+ context.write(context.json ? envelopeJson({ ...result, command }) : result.ok ? (lines ?? []).join('\n') : result.message ?? 'The merge was refused.');
126
+ return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
127
+ };
128
+ const fail = (reason, message) => emit({ ok: false, reason, message });
129
+ const allowed = new Set(['json', 'db', 'as', 'token']);
130
+ for (const key of flags.keys())
131
+ if (!allowed.has(key))
132
+ return fail('usage', `--${key} is not a task merge option.`);
133
+ const task = positional[0];
134
+ if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
135
+ return fail('usage', 'Use task merge <task> --as <you> --token <password>.');
136
+ if (context.demo)
137
+ return fail('demo', 'The demo merges nothing.');
138
+ const acting = await context.credentials();
139
+ if (acting === null)
140
+ return fail('unauthenticated', 'Merging takes your password: --as <you> --token <password>.');
141
+ const { store } = context;
142
+ if (!authenticateApprover(store, acting.name, acting.token).ok)
143
+ return fail('not-an-approver', 'That password didn\'t work, or you can\'t merge.');
144
+ const family = store.taskFamilyOf(task, store.knownRepos().filter(repo => store.accountCanAccess(acting.name, repo)), false);
145
+ if (family === null)
146
+ return fail('not-found', 'That task is unavailable in your projects.');
147
+ const view = newestPullRequestOf(store, family.versions.map(one => one.id));
148
+ if (view === null)
149
+ return fail('no-pr', 'This task has no pull request. Open one with task complete --pull-request.');
150
+ const merged = await mergeAsPerson(store, { runId: view.runId, name: acting.name, password: acting.token, ...(context.exec === undefined ? {} : { exec: context.exec }), clock: context.clock });
151
+ if (!merged.ok)
152
+ return emit({ ...merged, reason: merged.reason === 'password' ? 'not-an-approver' : merged.reason, pullRequest: view });
153
+ const after = newestPullRequestOf(store, family.versions.map(one => one.id));
154
+ return emit({ ok: true, pullRequest: after }, [
155
+ `${family.root.id} · Merged`,
156
+ `Pull request: ${after?.prUrl ?? view.prUrl ?? `#${view.prNumber}`}`,
157
+ `Merge commit: ${merged.commit ?? 'recorded by GitHub'} (${after?.mergeMethod ?? view.mergeMethod}, branch deleted)`,
158
+ ]);
159
+ }
160
+ /** The lines `task show` prints for a task's pull request. */
161
+ export function pullRequestLines(view) {
162
+ if (view === null)
163
+ return [];
164
+ return [` pull request: ${view.prUrl ?? 'opening'} · ${view.label}`, ` ${view.detail}`, ...(view.mergeCommit === null ? [] : [` merge commit: ${view.mergeCommit}`])];
165
+ }
@@ -187,7 +187,7 @@ function storedResultStatusOf(result, publication) {
187
187
  label: "Result saved, but its record does not match",
188
188
  // A structural refutation is settled before the approved check is
189
189
  // weighed, so it says nothing about whether that check passed.
190
- detail: withPublication("The proof's claims disagree with the sealed record. Whether the approved check passed is not settled by this verdict."),
190
+ detail: withPublication("What the agent reported doesn't match the changes it saved. This doesn't say whether the project check passed."),
191
191
  tone: "problem",
192
192
  action: { label: "Open the record", kind: "open-review" },
193
193
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"