@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.34 → 0.1.37
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/bin/adl +2 -2
- package/bin/reconcile +4 -3
- package/package.json +1 -1
- package/packages/agent-hooks/README.md +14 -0
- package/packages/agent-hooks/assets/opencode-plugin.template.ts +140 -104
- package/packages/agent-hooks/deploy-profile.json +1 -1
- package/packages/agent-hooks/src/deploy/opencode-template.ts +5 -18
- package/packages/agent-hooks/src/deploy/render.ts +12 -3
- package/packages/agent-hooks/src/pre-tool-use/handler.ts +2 -2
- package/packages/agent-hooks/src/rules/impl/adl-build-run-role.ts +116 -0
- package/packages/agent-hooks/src/rules/impl/adl-git-user-override-block.ts +159 -0
- package/packages/agent-hooks/src/rules/impl/adl-mcp-cli.ts +12 -6
- package/packages/agent-hooks/src/rules/impl/adl-pr-complete-pending.ts +9 -9
- package/packages/agent-hooks/src/rules/impl/adl-pr-role.ts +6 -5
- package/packages/agent-hooks/src/rules/impl/agent-tamper-protection.ts +15 -14
- package/packages/agent-hooks/src/rules/impl/git-core.ts +4 -8
- package/packages/agent-hooks/src/rules/impl/mcp-tool.ts +1 -1
- package/packages/agent-hooks/src/rules/impl/tool-native-search.ts +8 -7
- package/packages/agent-hooks/src/rules/registry.ts +11 -2
- package/packages/agent-hooks/src/rules/shell/protected-carriers.ts +9 -5
- package/packages/agent-settings/README.md +10 -4
- package/packages/agent-settings/assets/TARUN-GLOBAL-AGENTS-INSTRUCTIONS.md +40 -0
- package/packages/agent-settings/src/global-agents.ts +43 -8
- package/packages/agent-skills/assets/adl-mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/agent-search-tool-audit/scripts/audit.sh +1 -1
- package/packages/agent-skills/assets/code-review/SKILL.md +84 -66
- package/packages/agent-skills/assets/diagnosing-bugs/SKILL.md +23 -2
- package/packages/agent-skills/assets/diagnosing-bugs/references/FEEDBACK-LOOP-TACTICS.md +1 -1
- package/packages/agent-skills/assets/explore-codebase/SKILL.md +3 -2
- package/packages/agent-skills/assets/finalize-feature/SKILL.md +63 -0
- package/packages/agent-skills/assets/fix-ci/SKILL.md +58 -0
- package/packages/agent-skills/assets/handoff/SKILL.md +1 -1
- package/packages/agent-skills/assets/implement-feature/SKILL.md +153 -0
- package/packages/agent-skills/assets/implement-feature/references/briefs.md +94 -0
- package/packages/agent-skills/assets/implement-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/mechanics/SKILL.md +38 -0
- package/packages/agent-skills/assets/plan-feature/SKILL.md +141 -0
- package/packages/agent-skills/assets/prepare-promotion/SKILL.md +91 -0
- package/packages/agent-skills/assets/ralplan/SKILL.md +149 -0
- package/packages/agent-skills/assets/refactor-safely/SKILL.md +4 -3
- package/packages/agent-skills/assets/report-bug/SKILL.md +72 -0
- package/packages/agent-skills/assets/review-change/SKILL.md +82 -0
- package/packages/agent-skills/assets/review-plan/SKILL.md +124 -0
- package/packages/agent-skills/assets/to-feature/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/SKILL.md +1 -1
- package/packages/agent-skills/assets/to-workitems/references/AZURE-DEVOPS-HIERARCHY.md +3 -0
- package/packages/agent-skills/assets/triage-workitems/SKILL.md +99 -0
- package/packages/agent-skills/assets/ultragoal/SKILL.md +77 -0
- package/packages/agent-skills/assets/validate-design/SKILL.md +70 -0
- package/packages/agent-skills/assets/verify-item/SKILL.md +71 -0
- package/packages/agent-skills/assets/workitems-process/SKILL.md +147 -0
- package/packages/agent-skills/assets/workitems-process/references/ado-and-git.md +194 -0
- package/packages/agent-skills/assets/workitems-process/references/evidence.md +74 -0
- package/packages/agent-skills/assets/workitems-process/references/workitem-states.md +92 -0
- package/packages/agent-skills/assets/write-feature/SKILL.md +95 -0
- package/packages/agent-skills/dist/deploy.d.ts +7 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +52 -5
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +48 -4
- package/packages/azdo/scripts/probe-ado-rules.ts +1 -1
- package/packages/azdo/src/activity-log.ts +118 -0
- package/packages/azdo/src/azdo-attachments.ts +108 -0
- package/packages/azdo/src/azdo-pl.ts +100 -3
- package/packages/azdo/src/azdo-pr.ts +194 -11
- package/packages/azdo/src/azdo-wit.ts +329 -18
- package/packages/azdo/src/cli.ts +42 -2
- package/packages/azdo/src/lib/agent-identity.ts +1 -1
- package/packages/azdo/src/lib/client.ts +7 -1
- package/packages/azdo/src/lib/validate-pr-metadata.ts +14 -5
- package/packages/azdo/src/mcp-server.ts +53 -5
- package/packages/azdo/src/tool-dispatch.ts +53 -4
- package/packages/azdo/src/workflow-ladder.csv +125 -107
- package/packages/azdo/src/workflow-rules.ts +254 -97
- package/packages/repo-controls/README.md +24 -11
- package/packages/repo-controls/assets/scaffold/docs/AGENTS.md +5 -5
- package/packages/repo-controls/assets/scaffold/plans/AGENTS.md +42 -13
- package/packages/repo-controls/assets/scaffold/root/AGENTS.md +60 -11
- package/packages/repo-controls/assets/scaffold/root/CONTRIBUTING.md +28 -0
- package/packages/repo-controls/assets/scaffold/root/INVARIANTS.md +17 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/closed_set/test_check.py +0 -11
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/filename/test_check.py +4 -6
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/ledger/test_check.py +15 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/docs-okf-profiles.yaml +1 -17
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/docs/okf_profile/test_check.py +3 -22
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/check.py +39 -182
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/fixtures/open-work-cap-clean/plans/features/az601-clean/intent.md +17 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/open_work_cap/test_check.py +90 -321
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plan_metadata.py +31 -5
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/check.py +282 -54
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/design.md +35 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/file-map-reference.md +19 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/intent.md +25 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/items/az351-restore-entrypoint.md +25 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-clean/plans/features/az347-test-coverage/requirements.md +44 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/fixtures/plans-layout-violation/plans/features/az347-test-coverage/requirements.md +44 -0
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/checks/layout/plans_layout/test_check.py +646 -53
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/cli.py +3 -3
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/docs_shape.py +1 -12
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.alt.yaml +1 -1
- package/packages/repo-controls/package/adl-compliance/src/adl_compliance/policy/gate-registry.yaml +1 -1
- package/packages/repo-controls/src/cli.ts +23 -5
- package/packages/repo-controls/src/constants.ts +2 -15
- package/packages/repo-controls/src/install.ts +15 -6
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** A1 agent activity log — design: docs/research/adl-process-handover/process-design.md § Activity log. */
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { resolveAgentIdentity } from './lib/agent-identity.ts';
|
|
7
|
+
|
|
8
|
+
export interface ActivityEntry {
|
|
9
|
+
time: string;
|
|
10
|
+
surface: string;
|
|
11
|
+
agent: string;
|
|
12
|
+
repo: string;
|
|
13
|
+
note: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Verified child-process surface markers, first match wins; a marker is listed only with a citation to the surface's own docs. */
|
|
17
|
+
const SURFACE_MARKERS: ReadonlyArray<{ readonly surface: string; readonly env: string; readonly equals?: string }> = [
|
|
18
|
+
// Claude Code spawns set CLAUDECODE=1 (code.claude.com/docs/en/env-vars, verified 2026-10-12).
|
|
19
|
+
{ surface: 'claude', env: 'CLAUDECODE', equals: '1' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** The surface this process runs under: first verified marker match, else 'cli' (no marker is not an error). */
|
|
23
|
+
export function resolveSurface(env: NodeJS.ProcessEnv = process.env): string {
|
|
24
|
+
for (const marker of SURFACE_MARKERS) {
|
|
25
|
+
const value = env[marker.env];
|
|
26
|
+
if (value !== undefined && (marker.equals === undefined || value === marker.equals)) {
|
|
27
|
+
return marker.surface;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return 'cli';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The git work-tree top level of `cwd` (or the process's own), or a loud refusal outside a work tree — the log lives at `<repo>/.adl/agent-activity.sqlite`. */
|
|
34
|
+
export function repoTopLevel(cwd?: string): string {
|
|
35
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
36
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 5000,
|
|
39
|
+
});
|
|
40
|
+
const topLevel = result.status === 0 ? result.stdout.trim() : '';
|
|
41
|
+
if (!topLevel) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'the activity log lives at <repo>/.adl/agent-activity.sqlite, and this working directory is not a git work tree',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return topLevel;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The ONE place the log's path is built. */
|
|
50
|
+
export function activityDbPath(cwd?: string): string {
|
|
51
|
+
return join(repoTopLevel(cwd), '.adl', 'agent-activity.sqlite');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function openDb(cwd?: string): DatabaseSync {
|
|
55
|
+
const path = activityDbPath(cwd);
|
|
56
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
57
|
+
const db = new DatabaseSync(path);
|
|
58
|
+
db.exec(
|
|
59
|
+
`CREATE TABLE IF NOT EXISTS activity (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
time TEXT NOT NULL,
|
|
62
|
+
surface TEXT NOT NULL,
|
|
63
|
+
agent TEXT NOT NULL,
|
|
64
|
+
repo TEXT NOT NULL,
|
|
65
|
+
note TEXT NOT NULL
|
|
66
|
+
)`,
|
|
67
|
+
);
|
|
68
|
+
return db;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertNote(note: unknown): asserts note is string {
|
|
72
|
+
if (typeof note !== 'string' || note.trim() === '') {
|
|
73
|
+
throw new Error('note must be a non-empty string');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Append one entry, filling time/surface/agent/repo; `env`/`now` keep tests hermetic. Returns the stored entry as JSON. */
|
|
78
|
+
export function activityAdd(
|
|
79
|
+
note: unknown,
|
|
80
|
+
opts: { cwd?: string; env?: NodeJS.ProcessEnv; now?: Date } = {},
|
|
81
|
+
): string {
|
|
82
|
+
assertNote(note);
|
|
83
|
+
const env = opts.env ?? process.env;
|
|
84
|
+
const entry: ActivityEntry = {
|
|
85
|
+
time: (opts.now ?? new Date()).toISOString(),
|
|
86
|
+
surface: resolveSurface(env),
|
|
87
|
+
agent: resolveAgentIdentity(env).agent,
|
|
88
|
+
repo: basename(repoTopLevel(opts.cwd)),
|
|
89
|
+
note,
|
|
90
|
+
};
|
|
91
|
+
const db = openDb(opts.cwd);
|
|
92
|
+
try {
|
|
93
|
+
db.prepare('INSERT INTO activity (time, surface, agent, repo, note) VALUES (?, ?, ?, ?, ?)').run(
|
|
94
|
+
entry.time,
|
|
95
|
+
entry.surface,
|
|
96
|
+
entry.agent,
|
|
97
|
+
entry.repo,
|
|
98
|
+
entry.note,
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
db.close();
|
|
102
|
+
}
|
|
103
|
+
return JSON.stringify(entry, null, 2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every entry, oldest first (the reading order of the ledger this replaces). */
|
|
107
|
+
export function activityList(opts: { cwd?: string } = {}): string {
|
|
108
|
+
const db = openDb(opts.cwd);
|
|
109
|
+
let entries: unknown[];
|
|
110
|
+
try {
|
|
111
|
+
entries = db
|
|
112
|
+
.prepare('SELECT time, surface, agent, repo, note FROM activity ORDER BY id ASC')
|
|
113
|
+
.all();
|
|
114
|
+
} finally {
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
return JSON.stringify({ repo: basename(repoTopLevel(opts.cwd)), entries }, null, 2);
|
|
118
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// T2 work-item attachments (board-rules.md § Attachments): Features only, frozen once attached — the one delete allowed is a mistaken upload in its own run, for a plan-r* file while every Feature item is still at or before Awaiting Approval. API facts (WIT REST 7.1): upload POSTs .../_apis/wit/attachments?fileName={name} (octet-stream, returns { id, url }); attach PATCHes the work item adding an AttachedFile relation (test/rev gives optimistic concurrency); Attachments-Delete is 7.2-preview only, so delete detaches and the stored file stays.
|
|
2
|
+
import { AdoClient } from './lib/client.ts';
|
|
3
|
+
|
|
4
|
+
let client: AdoClient | undefined
|
|
5
|
+
|
|
6
|
+
async function getClient(): Promise<AdoClient> {
|
|
7
|
+
client ??= new AdoClient()
|
|
8
|
+
return client
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Test seam: forget the cached client — see azdo-wit.ts's resetClientForTests() for why. */
|
|
12
|
+
export function resetClientForTests(): void {
|
|
13
|
+
client = undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type WorkitemAttachmentsArgs = {
|
|
17
|
+
id: number;
|
|
18
|
+
action: 'read' | 'upload' | 'delete';
|
|
19
|
+
/** upload/delete: the attachment file name. */
|
|
20
|
+
fileName?: string;
|
|
21
|
+
/** upload: the file content, sent as an octet-stream body. */
|
|
22
|
+
content?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface AttachmentRelation {
|
|
26
|
+
index: number;
|
|
27
|
+
name: string;
|
|
28
|
+
url: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function attachedFiles(item: any): AttachmentRelation[] {
|
|
32
|
+
return (item.relations ?? [])
|
|
33
|
+
.map((relation: any, index: number) => ({ relation, index }))
|
|
34
|
+
.filter(({ relation }: any) => relation.rel === 'AttachedFile')
|
|
35
|
+
.map(({ relation, index }: any) => ({
|
|
36
|
+
index,
|
|
37
|
+
name: relation.attributes?.name ?? relation.url,
|
|
38
|
+
url: relation.url,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** P4 reuses this upload when it attaches the mandatory plan/impl report at PR raise. */
|
|
43
|
+
export async function uploadAttachment(c: AdoClient, workItemId: number, fileName: string, content: string): Promise<void> {
|
|
44
|
+
const uploaded = await c.post<any>(
|
|
45
|
+
'/wit/attachments',
|
|
46
|
+
content,
|
|
47
|
+
{ fileName, 'api-version': '7.1' },
|
|
48
|
+
'application/octet-stream',
|
|
49
|
+
);
|
|
50
|
+
if (!uploaded?.url) throw new Error(`attachment upload for '${fileName}' returned no url`);
|
|
51
|
+
const item = await c.get<any>(`/wit/workitems/${workItemId}`, { 'api-version': '7.1' });
|
|
52
|
+
await c.patch<any>(
|
|
53
|
+
`/wit/workitems/${workItemId}`,
|
|
54
|
+
[
|
|
55
|
+
{ op: 'test', path: '/rev', value: item.rev ?? item.fields?.['System.Rev'] },
|
|
56
|
+
{ op: 'add', path: '/relations/-', value: { rel: 'AttachedFile', url: uploaded.url } },
|
|
57
|
+
],
|
|
58
|
+
{ 'api-version': '7.1' },
|
|
59
|
+
'application/json-patch+json',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The T2 guard (board-rules.md § Attachments): a plan-r* file refuses deletion once any item of its Feature has left Awaiting Approval — the plan G1 approved stays on the board. */
|
|
64
|
+
async function assertPlanAttachmentDeletable(c: AdoClient, feature: any, fileName: string): Promise<void> {
|
|
65
|
+
const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
|
|
66
|
+
const childIds = (feature.relations ?? [])
|
|
67
|
+
.filter((relation: any) => relation.rel === 'System.LinkTypes.Hierarchy-Forward')
|
|
68
|
+
.map((relation: any) => relationId(relation.url))
|
|
69
|
+
.filter((id: number) => Number.isInteger(id));
|
|
70
|
+
if (!childIds.length) return;
|
|
71
|
+
const children = await c.post<any>('/wit/workitemsbatch', { ids: childIds, $expand: 'all' }, { 'api-version': '7.1' });
|
|
72
|
+
const left = (children.value ?? []).find((child: any) => !['New', 'In Planning', 'Awaiting Approval'].includes(child.fields?.['System.State']));
|
|
73
|
+
if (left) {
|
|
74
|
+
throw new Error(`refusing to delete '${fileName}': an item of Feature ${feature.id} has left Awaiting Approval (item ${left.id} is at ${left.fields?.['System.State']}) — the approved plan stays on the board`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function workitemAttachments(args: WorkitemAttachmentsArgs): Promise<string> {
|
|
79
|
+
const c = await getClient();
|
|
80
|
+
if (args.action === 'upload') {
|
|
81
|
+
if (!args.fileName) throw new Error('fileName is required for upload');
|
|
82
|
+
if (args.content === undefined || args.content === '') throw new Error('content is required for upload');
|
|
83
|
+
await uploadAttachment(c, args.id, args.fileName, args.content);
|
|
84
|
+
return JSON.stringify({ id: args.id, attached: args.fileName }, null, 2);
|
|
85
|
+
}
|
|
86
|
+
const item = await c.get<any>(`/wit/workitems/${args.id}`, { 'api-version': '7.1', $expand: 'all' });
|
|
87
|
+
const files = attachedFiles(item);
|
|
88
|
+
if (args.action === 'read') {
|
|
89
|
+
return JSON.stringify({ id: args.id, attachments: files.map(({ name, url }) => ({ name, url })) }, null, 2);
|
|
90
|
+
}
|
|
91
|
+
if (!args.fileName) throw new Error('fileName is required for delete');
|
|
92
|
+
const target = files.find((file) => file.name === args.fileName);
|
|
93
|
+
if (!target) throw new Error(`work item ${args.id} has no AttachedFile named '${args.fileName}'`);
|
|
94
|
+
if (target.name.startsWith('plan-r')) {
|
|
95
|
+
await assertPlanAttachmentDeletable(c, item, target.name);
|
|
96
|
+
}
|
|
97
|
+
// On 7.1 delete detaches: remove the relation, the stored file stays; test/rev gives the same optimistic concurrency as attach.
|
|
98
|
+
await c.patch<any>(
|
|
99
|
+
`/wit/workitems/${args.id}`,
|
|
100
|
+
[
|
|
101
|
+
{ op: 'test', path: '/rev', value: item.rev ?? item.fields?.['System.Rev'] },
|
|
102
|
+
{ op: 'remove', path: `/relations/${target.index}` },
|
|
103
|
+
],
|
|
104
|
+
{ 'api-version': '7.1' },
|
|
105
|
+
'application/json-patch+json',
|
|
106
|
+
);
|
|
107
|
+
return JSON.stringify({ id: args.id, deleted: target.name }, null, 2);
|
|
108
|
+
}
|
|
@@ -9,10 +9,38 @@
|
|
|
9
9
|
* automatically) falling back to the AdoClient defaults. Nothing is
|
|
10
10
|
* hardcoded here.
|
|
11
11
|
*
|
|
12
|
+
* buildRun carries the second half of H1
|
|
13
|
+
* (docs/research/adl-process-handover/board-rules.md § Hooks, row
|
|
14
|
+
* `adl.build-run-role`): the hook has no network access, so it grades only
|
|
15
|
+
* what the call itself proves and names this function as the re-verifier of
|
|
16
|
+
* the caller-declared claim. Before queuing, buildRun requires ALL of:
|
|
17
|
+
* 1. `branch` present (the hook requires it too; the tool refuses on its
|
|
18
|
+
* own, never relying on the hook alone);
|
|
19
|
+
* 2. an OPEN pull request in this repository whose source branch equals
|
|
20
|
+
* the call's `branch` (after `refs/heads/` removal);
|
|
21
|
+
* 3. that PR raised by the CALLING agent identity — `createdBy.id`
|
|
22
|
+
* compared against lib/graph-identity.ts's resolveAgentIdentityId, the
|
|
23
|
+
* ONE identity-form producer in this repo;
|
|
24
|
+
* 4. the named definition is that PR's validation pipeline: a run of
|
|
25
|
+
* definition `id` exists on the PR's merge ref (`refs/pull/<id>/merge`,
|
|
26
|
+
* the sourceBranch ADO queues PR validation builds under — the same ref
|
|
27
|
+
* build_list_or_get's poll hint names).
|
|
28
|
+
* RESIDUAL APPROXIMATION (stated, not silently widened): check 4 proves
|
|
29
|
+
* the definition IS a validation pipeline of THIS PR via a run that
|
|
30
|
+
* already exists; a definition whose policy would queue on the PR but has
|
|
31
|
+
* never produced a run is refused. That is sound for the re-queue use case
|
|
32
|
+
* (an expired check implies a run that existed) but stricter than "any
|
|
33
|
+
* policy-bound definition" — the PR resource carries no build-policy
|
|
34
|
+
* reference (`completionOptions` names no definition), so an existing run
|
|
35
|
+
* on the merge ref is the strongest relationship the REST API exposes.
|
|
36
|
+
*
|
|
12
37
|
* Timeline model: Stage → Phase → Job → Task, chained by parentId
|
|
13
38
|
* (Checkpoint records carry no user-facing state and are skipped).
|
|
14
39
|
*/
|
|
15
|
-
import { AdoClient } from './lib/client.ts'
|
|
40
|
+
import { AdoClient, resolveRepository } from './lib/client.ts'
|
|
41
|
+
import { resolveAgentIdentity } from './lib/agent-identity.ts';
|
|
42
|
+
import { resolveAgentIdentityId } from './lib/graph-identity.ts';
|
|
43
|
+
import { pullRequestsPath } from './azdo-pr.ts'
|
|
16
44
|
|
|
17
45
|
let client: AdoClient | undefined
|
|
18
46
|
|
|
@@ -21,6 +49,11 @@ async function getClient(): Promise<AdoClient> {
|
|
|
21
49
|
return client
|
|
22
50
|
}
|
|
23
51
|
|
|
52
|
+
/** Test seam: forget the cached client — see azdo-pr.ts's resetClientForTests() for why. */
|
|
53
|
+
export function resetClientForTests(): void {
|
|
54
|
+
client = undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
/** GET a JSON payload. */
|
|
25
58
|
async function getJson<T>(c: AdoClient, path: string, params?: Record<string, string>): Promise<T> {
|
|
26
59
|
return c.get<T>(path, { 'api-version': '7.1', ...params });
|
|
@@ -160,13 +193,77 @@ export async function buildListOrGet(args: BuildListOrGetArgs = {}): Promise<str
|
|
|
160
193
|
|
|
161
194
|
export type BuildRunArgs = { id: string; branch?: string };
|
|
162
195
|
|
|
196
|
+
function normalizeBranchRef(ref: string): string {
|
|
197
|
+
return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* H1's azdo half — see this file's header. Queries (all read-only, all
|
|
202
|
+
* before the queue POST): the open PRs on the named source branch, the
|
|
203
|
+
* caller's own ADO identity id (Graph Subject Query → Storage Keys), and the
|
|
204
|
+
* validation runs on the PR's merge ref. Throws with the exact failed check;
|
|
205
|
+
* queues only when every check passes.
|
|
206
|
+
*/
|
|
207
|
+
async function assertOwnPrValidationBuild(c: AdoClient, definitionId: number, branch: string): Promise<void> {
|
|
208
|
+
const sourceBranch = normalizeBranchRef(branch.trim());
|
|
209
|
+
const repository = resolveRepository();
|
|
210
|
+
const { repositoryId } = await resolveRepositoryId(c);
|
|
211
|
+
const agent = resolveAgentIdentity().agent;
|
|
212
|
+
|
|
213
|
+
const prs = await getJson<any>(c, pullRequestsPath(repository), {
|
|
214
|
+
'searchCriteria.status': 'active',
|
|
215
|
+
'searchCriteria.sourceRefName': `refs/heads/${sourceBranch}`,
|
|
216
|
+
'$top': '100',
|
|
217
|
+
});
|
|
218
|
+
const candidates = (prs.value ?? []) as any[];
|
|
219
|
+
if (candidates.length === 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`build_run refused (H1): no open pull request in '${repository}' has source branch '${sourceBranch}' — ` +
|
|
222
|
+
'build_run may only re-queue the validation pipeline of your own open PR, on that PR\'s source branch',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const callerId = await resolveAgentIdentityId(agent);
|
|
227
|
+
const mine = candidates.filter((pr) => pr.createdBy?.id === callerId);
|
|
228
|
+
if (mine.length === 0) {
|
|
229
|
+
const authors = [...new Set(candidates.map((pr) => pr.createdBy?.displayName ?? 'unknown'))].join(', ');
|
|
230
|
+
throw new Error(
|
|
231
|
+
`build_run refused (H1): the open pull request on '${sourceBranch}' was raised by ${authors}, not ${agent} — ` +
|
|
232
|
+
'build_run may only re-queue the validation pipeline of a PR you raised yourself',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const pr of mine) {
|
|
237
|
+
const runs = await getJson<any>(c, '/build/builds', {
|
|
238
|
+
branchName: `refs/pull/${pr.pullRequestId}/merge`,
|
|
239
|
+
repositoryId,
|
|
240
|
+
repositoryType: 'TfsGit',
|
|
241
|
+
'$top': '50',
|
|
242
|
+
});
|
|
243
|
+
if ((runs.value ?? []).some((run: any) => run.definition?.id === definitionId)) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const prIds = mine.map((pr) => pr.pullRequestId).join(', ');
|
|
248
|
+
throw new Error(
|
|
249
|
+
`build_run refused (H1): build definition ${definitionId} is not a validation pipeline of your pull request(s) ${prIds} ` +
|
|
250
|
+
`(no run of it exists on refs/pull/<id>/merge) — pass the definition id of your PR's validation build, from build_list_or_get`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
163
254
|
export async function buildRun(args: BuildRunArgs): Promise<string> {
|
|
164
255
|
if (!args.id) throw new Error('id is required');
|
|
165
256
|
const definitionId = Number(args.id);
|
|
166
257
|
if (!Number.isInteger(definitionId) || definitionId < 1) throw new Error(`id must be a positive integer definition id; got '${args.id}'`);
|
|
258
|
+
if (typeof args.branch !== 'string' || args.branch.trim() === '') {
|
|
259
|
+
throw new Error(
|
|
260
|
+
'branch is required: name the source branch of your own open PR — build_run may only re-queue that PR\'s ' +
|
|
261
|
+
'validation pipeline, on that PR\'s source branch (H1)',
|
|
262
|
+
);
|
|
263
|
+
}
|
|
167
264
|
const c = await getClient()
|
|
168
|
-
|
|
169
|
-
|
|
265
|
+
await assertOwnPrValidationBuild(c, definitionId, args.branch);
|
|
266
|
+
const body: any = { definition: { id: definitionId }, sourceBranch: args.branch };
|
|
170
267
|
const build = await c.post<any>('/build/builds', body, { 'api-version': '7.1' });
|
|
171
268
|
return JSON.stringify({
|
|
172
269
|
id: build.id,
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// repo invoked this file as a CLI; see mcp-server.ts's runTool() history).
|
|
4
4
|
import {
|
|
5
5
|
validatePullRequestDescription,
|
|
6
|
-
validatePullRequestMetadata,
|
|
7
6
|
validatePullRequestTitle,
|
|
8
7
|
} from './lib/validate-pr-metadata.ts'
|
|
9
8
|
import { AdoClient, PR_TARGET_BRANCH, adoProjectUrl, currentBranch, resolveRepository } from './lib/client.ts';
|
|
9
|
+
import { uploadAttachment } from './azdo-attachments.ts';
|
|
10
|
+
import { evaluateWorkflowTransition, formatWorkflowRefusal } from './workflow-rules.ts';
|
|
10
11
|
|
|
11
12
|
let client: AdoClient | undefined
|
|
12
13
|
|
|
@@ -34,17 +35,22 @@ export function pullRequestWebUrl(repository: string, pullRequestId: number | st
|
|
|
34
35
|
* the call succeeded, the tool reported success, and the PR stayed unarmed.
|
|
35
36
|
* That silent no-op is why this is a named function with its own test rather
|
|
36
37
|
* than an object literal at the call site.
|
|
38
|
+
*
|
|
39
|
+
* P2: `completionOptions.transitionWorkItems` is set by PR kind — true only
|
|
40
|
+
* for implementation and Fixes Bug PRs, where ADO moves the linked items to
|
|
41
|
+
* Done when the PR merges into integration (planning and ordinary PRs move
|
|
42
|
+
* nothing on merge).
|
|
37
43
|
*/
|
|
38
|
-
export function autoCompleteBody(identityId: string): {
|
|
44
|
+
export function autoCompleteBody(identityId: string, transitionWorkItems = false): {
|
|
39
45
|
autoCompleteSetBy: { id: string }
|
|
40
|
-
completionOptions: { mergeStrategy: string; deleteSourceBranch: boolean }
|
|
46
|
+
completionOptions: { mergeStrategy: string; deleteSourceBranch: boolean; transitionWorkItems: boolean }
|
|
41
47
|
} {
|
|
42
48
|
if (!identityId) {
|
|
43
49
|
throw new Error('cannot arm auto-complete: no identity to attribute it to')
|
|
44
50
|
}
|
|
45
51
|
return {
|
|
46
52
|
autoCompleteSetBy: { id: identityId },
|
|
47
|
-
completionOptions: { mergeStrategy: 'squash', deleteSourceBranch: true },
|
|
53
|
+
completionOptions: { mergeStrategy: 'squash', deleteSourceBranch: true, transitionWorkItems },
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -147,8 +153,11 @@ export async function prListOrGet(args: PrListOrGetArgs): Promise<string> {
|
|
|
147
153
|
return JSON.stringify(prs, null, 2);
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
function assertValidCreateMetadata(title: string, description: string) {
|
|
151
|
-
const metadataErrors =
|
|
156
|
+
function assertValidCreateMetadata(title: string, description: string, docsOnlySections: boolean) {
|
|
157
|
+
const metadataErrors = [
|
|
158
|
+
...validatePullRequestTitle(title),
|
|
159
|
+
...validatePullRequestDescription(description, { docsOnlySections }),
|
|
160
|
+
]
|
|
152
161
|
if (metadataErrors.length === 0) return
|
|
153
162
|
throw new Error(metadataErrors.map((error) => `pr-policy: FAIL — ${error}`).join('\n'))
|
|
154
163
|
}
|
|
@@ -169,6 +178,8 @@ export type PrCreateOrUpdateArgs = {
|
|
|
169
178
|
autoComplete?: boolean;
|
|
170
179
|
mergeStrategy?: string;
|
|
171
180
|
deleteSourceBranch?: boolean;
|
|
181
|
+
/** P4: the content of the kind's mandatory Feature attachment (planning/implementation PRs only). */
|
|
182
|
+
attachmentContent?: string;
|
|
172
183
|
/** Overrides `AZURE_DEVOPS_REPO` for this call. */
|
|
173
184
|
repository?: string;
|
|
174
185
|
/** Rejected removed routing input; PRs always use the checked-out branch. */
|
|
@@ -184,8 +195,130 @@ export function assertPrSourceBranch(source: string | undefined): asserts source
|
|
|
184
195
|
if (source === 'main') throw new Error(`a pull request is never opened from main; open a branch into ${PR_TARGET_BRANCH}`);
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
/**
|
|
199
|
+
* P1: the PR kind comes from the source branch naming a work item
|
|
200
|
+
* (azdo-git-process.md § PRs). `docs/az<id>-<slug>-plan` is a planning PR;
|
|
201
|
+
* `fix/az<bug id>-<slug>` is a Fixes Bug implementation PR naming one Bug;
|
|
202
|
+
* any other `<type>/az<id>-<slug>` branch names a Feature and is an
|
|
203
|
+
* implementation PR. Anything else is ordinary. The kind can still fall
|
|
204
|
+
* back to ordinary when the board holds no item in a wireable state.
|
|
205
|
+
*/
|
|
206
|
+
export type PrKind = 'planning' | 'implementation' | 'ordinary';
|
|
207
|
+
|
|
208
|
+
export interface PrKindTarget {
|
|
209
|
+
kind: PrKind;
|
|
210
|
+
/** The work item the branch names (undefined for ordinary). */
|
|
211
|
+
itemId?: number;
|
|
212
|
+
itemType?: 'Feature' | 'Bug';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const PLANNING_BRANCH = /^docs\/az(\d+)-[a-z0-9-]+-plan$/;
|
|
216
|
+
const FIXES_BUG_BRANCH = /^fix\/az(\d+)-[a-z0-9-]+$/;
|
|
217
|
+
const FEATURE_BRANCH = /^(?:feat|chore|docs|perf|refactor)\/az(\d+)-[a-z0-9-]+$/;
|
|
218
|
+
|
|
219
|
+
export function derivePrKind(sourceBranch: string): PrKindTarget {
|
|
220
|
+
const planning = PLANNING_BRANCH.exec(sourceBranch);
|
|
221
|
+
if (planning) return { kind: 'planning', itemId: Number(planning[1]), itemType: 'Feature' };
|
|
222
|
+
const fixes = FIXES_BUG_BRANCH.exec(sourceBranch);
|
|
223
|
+
if (fixes) return { kind: 'implementation', itemId: Number(fixes[1]), itemType: 'Bug' };
|
|
224
|
+
const feature = FEATURE_BRANCH.exec(sourceBranch);
|
|
225
|
+
if (feature) return { kind: 'implementation', itemId: Number(feature[1]), itemType: 'Feature' };
|
|
226
|
+
return { kind: 'ordinary' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The board states P1 wires, per kind. Done is never wired (terminal); neither are the parked/removed states. */
|
|
230
|
+
const PLANNING_WIREABLE: ReadonlySet<string> = new Set(['In Planning']);
|
|
231
|
+
const PBI_IMPLEMENTATION_WIREABLE: ReadonlySet<string> = new Set(['Approved', 'Design Validation', 'In Implementation', 'In Testing', 'In Review', 'Updating Docs']);
|
|
232
|
+
const BUG_IMPLEMENTATION_WIREABLE: ReadonlySet<string> = new Set(['Approved', 'In Reproduction', 'In Fixing', 'In Testing', 'In Review', 'Updating Docs']);
|
|
233
|
+
|
|
234
|
+
/** P2's raise-time move targets: planning items → Awaiting Approval; loop items → Updating Docs. */
|
|
235
|
+
const PBI_LOOP: ReadonlySet<string> = new Set(['In Implementation', 'In Testing', 'In Review', 'Updating Docs']);
|
|
236
|
+
const BUG_LOOP: ReadonlySet<string> = new Set(['In Fixing', 'In Testing', 'In Review', 'Updating Docs']);
|
|
237
|
+
|
|
238
|
+
export interface WiredItem {
|
|
239
|
+
id: number;
|
|
240
|
+
type: 'Product Backlog Item' | 'Bug';
|
|
241
|
+
state: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function relationIds(item: any, rel: string): number[] {
|
|
245
|
+
const relationId = (url: string) => Number(/(\d+)\/?$/.exec(url)?.[1]);
|
|
246
|
+
return (item.relations ?? [])
|
|
247
|
+
.filter((relation: any) => relation.rel === rel)
|
|
248
|
+
.map((relation: any) => relationId(relation.url))
|
|
249
|
+
.filter((id: number) => Number.isInteger(id));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* P1: the items to wire, read from the board. Planning wires the Feature's
|
|
254
|
+
* in-scope items at In Planning; implementation wires the Feature's items
|
|
255
|
+
* (or the one Bug) at Approved or later. An empty result demotes the PR to
|
|
256
|
+
* ordinary — no links, no state moves, no attachment.
|
|
257
|
+
*/
|
|
258
|
+
export async function resolveWiredItems(c: AdoClient, target: PrKindTarget): Promise<WiredItem[]> {
|
|
259
|
+
if (target.itemId === undefined || target.itemType === undefined) return [];
|
|
260
|
+
const item = await c.get<any>(`/wit/workitems/${target.itemId}`, { 'api-version': '7.1', $expand: 'all' });
|
|
261
|
+
if (target.itemType === 'Bug') {
|
|
262
|
+
const state = item.fields?.['System.State'];
|
|
263
|
+
return BUG_IMPLEMENTATION_WIREABLE.has(state) ? [{ id: item.id, type: 'Bug', state }] : [];
|
|
264
|
+
}
|
|
265
|
+
const wireable = target.kind === 'planning' ? PLANNING_WIREABLE : PBI_IMPLEMENTATION_WIREABLE;
|
|
266
|
+
const childIds = relationIds(item, 'System.LinkTypes.Hierarchy-Forward');
|
|
267
|
+
if (!childIds.length) return [];
|
|
268
|
+
const batch = await c.post<any>('/wit/workitemsbatch', { ids: childIds, $expand: 'all' }, { 'api-version': '7.1' });
|
|
269
|
+
return (batch.value ?? [])
|
|
270
|
+
.filter((child: any) => ['Product Backlog Item', 'Bug'].includes(child.fields?.['System.WorkItemType']))
|
|
271
|
+
.filter((child: any) => wireable.has(child.fields?.['System.State']))
|
|
272
|
+
.map((child: any) => ({ id: child.id, type: child.fields['System.WorkItemType'], state: child.fields['System.State'] }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** P2's raise-time target state for one wired item, or undefined when the item stays where it is. */
|
|
276
|
+
export function raiseMoveTarget(kind: PrKind, item: WiredItem): string | undefined {
|
|
277
|
+
if (kind === 'planning') return 'Awaiting Approval';
|
|
278
|
+
if (kind === 'implementation') {
|
|
279
|
+
const loop = item.type === 'Bug' ? BUG_LOOP : PBI_LOOP;
|
|
280
|
+
return loop.has(item.state) && item.state !== 'Updating Docs' ? 'Updating Docs' : undefined;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* P2: move the linked items themselves when the PR is raised. Every move is
|
|
287
|
+
* pre-checked against the workflow fence (the same rule an agent write
|
|
288
|
+
* faces) so a PR is never raised half-wired.
|
|
289
|
+
*/
|
|
290
|
+
async function moveWiredItems(c: AdoClient, kind: PrKind, items: WiredItem[]): Promise<void> {
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
const to = raiseMoveTarget(kind, item);
|
|
293
|
+
if (to === undefined) continue;
|
|
294
|
+
const violations = evaluateWorkflowTransition(item.type, item.state, to);
|
|
295
|
+
if (violations.length) throw new Error(formatWorkflowRefusal(violations));
|
|
296
|
+
await c.patch<any>(
|
|
297
|
+
`/wit/workitems/${item.id}`,
|
|
298
|
+
[{ op: 'add', path: '/fields/System.State', value: to }],
|
|
299
|
+
{ 'api-version': '7.1' },
|
|
300
|
+
'application/json-patch+json',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* P4: the work item the mandatory attachment hangs off — the named Feature,
|
|
307
|
+
* or, for a Fixes Bug PR, its Fixes Feature (board-rules.md § Attachments).
|
|
308
|
+
* The file name is completed by the caller once the PR id exists.
|
|
309
|
+
*/
|
|
310
|
+
async function attachmentTargetWorkItemId(c: AdoClient, target: PrKindTarget): Promise<number> {
|
|
311
|
+
if (target.itemType === 'Bug') {
|
|
312
|
+
const bug = await c.get<any>(`/wit/workitems/${target.itemId}`, { 'api-version': '7.1', $expand: 'all' });
|
|
313
|
+
const [parentId] = relationIds(bug, 'System.LinkTypes.Hierarchy-Reverse');
|
|
314
|
+
if (parentId === undefined) throw new Error(`Fixes Bug ${target.itemId} has no parent Fixes Feature to attach the implementation report to (P4)`);
|
|
315
|
+
return parentId;
|
|
316
|
+
}
|
|
317
|
+
return target.itemId!;
|
|
318
|
+
}
|
|
319
|
+
|
|
187
320
|
/** The exact next steps returned after PR creation, including live-stage failure polling. */
|
|
188
|
-
export function prCreateResult(repository: string, pullRequestId: number, title: string, status: string, autoComplete: boolean): Record<string, unknown> {
|
|
321
|
+
export function prCreateResult(repository: string, pullRequestId: number, title: string, status: string, autoComplete: boolean, kind: PrKind = 'ordinary', wiredItems: number[] = []): Record<string, unknown> {
|
|
189
322
|
const prRef = `refs/pull/${pullRequestId}/merge`;
|
|
190
323
|
return {
|
|
191
324
|
id: pullRequestId,
|
|
@@ -193,6 +326,8 @@ export function prCreateResult(repository: string, pullRequestId: number, title:
|
|
|
193
326
|
status,
|
|
194
327
|
url: pullRequestWebUrl(repository, pullRequestId),
|
|
195
328
|
autoComplete,
|
|
329
|
+
kind,
|
|
330
|
+
workItems: wiredItems,
|
|
196
331
|
summary: 'Creating this pull request started a validation build.',
|
|
197
332
|
poll: [
|
|
198
333
|
`1. build_list_or_get({ branch: '${prRef}' }) — take builds[0].id.`,
|
|
@@ -258,9 +393,42 @@ export async function prCreateOrUpdate(args: PrCreateOrUpdateArgs): Promise<stri
|
|
|
258
393
|
assertPrSourceBranch(source);
|
|
259
394
|
if (!title) throw new Error('title is required for create');
|
|
260
395
|
|
|
261
|
-
assertValidCreateMetadata(title, description)
|
|
262
|
-
|
|
263
396
|
const c = await getClient()
|
|
397
|
+
|
|
398
|
+
// P1: derive the kind from the source branch AND the board. No item in a
|
|
399
|
+
// wireable state demotes the PR to ordinary — nothing links, moves or
|
|
400
|
+
// attaches (the intent.md PR is the standing example).
|
|
401
|
+
const kindTarget = derivePrKind(source);
|
|
402
|
+
const wired = kindTarget.kind === 'ordinary' ? [] : await resolveWiredItems(c, kindTarget);
|
|
403
|
+
const kind: PrKind = wired.length ? kindTarget.kind : 'ordinary';
|
|
404
|
+
|
|
405
|
+
// P3: a planning PR may write "none (docs-only)" in the last two sections.
|
|
406
|
+
assertValidCreateMetadata(title, description, kind === 'planning')
|
|
407
|
+
|
|
408
|
+
// P4: the agent supplies the mandatory attachment's content; a planning or
|
|
409
|
+
// implementation PR refuses to raise without it. The planning attachment's
|
|
410
|
+
// file name comes from the Feature's plan fields and is validated here,
|
|
411
|
+
// before any PR exists.
|
|
412
|
+
let planningAttachment: { workItemId: number; fileName: string } | undefined;
|
|
413
|
+
let implAttachmentTarget: number | undefined;
|
|
414
|
+
if (kind !== 'ordinary') {
|
|
415
|
+
if (args.attachmentContent === undefined || args.attachmentContent.trim() === '') {
|
|
416
|
+
const article = kind === 'implementation' ? 'an' : 'a';
|
|
417
|
+
throw new Error(`${article} ${kind} PR requires attachmentContent — the mandatory ${kind === 'planning' ? 'plan-r<N>-<sha7>.md plan attachment' : 'impl-report-pr<id>.md implementation report'} (P4); refusing to raise the PR without it`)
|
|
418
|
+
}
|
|
419
|
+
if (kind === 'planning') {
|
|
420
|
+
const feature = await c.get<any>(`/wit/workitems/${kindTarget.itemId}`, { 'api-version': '7.1' });
|
|
421
|
+
const revision = feature.fields?.['Custom.PlanRevision'];
|
|
422
|
+
const hash = feature.fields?.['Custom.PlanHash'];
|
|
423
|
+
if (typeof revision !== 'number' || typeof hash !== 'string' || !hash) {
|
|
424
|
+
throw new Error('a planning PR needs the Feature\'s Custom.PlanRevision and Custom.PlanHash to name the mandatory plan attachment (P4)')
|
|
425
|
+
}
|
|
426
|
+
planningAttachment = { workItemId: kindTarget.itemId!, fileName: `plan-r${revision}-${hash.slice(0, 7)}.md` };
|
|
427
|
+
} else {
|
|
428
|
+
implAttachmentTarget = await attachmentTargetWorkItemId(c, kindTarget);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
264
432
|
await assertBranchReadyForPr(c, repository, source, target)
|
|
265
433
|
const body: any = {
|
|
266
434
|
sourceRefName: `refs/heads/${source}`,
|
|
@@ -268,6 +436,9 @@ export async function prCreateOrUpdate(args: PrCreateOrUpdateArgs): Promise<stri
|
|
|
268
436
|
title,
|
|
269
437
|
description,
|
|
270
438
|
};
|
|
439
|
+
// P1: link the wired items at create, as `az repos pr create --work-items`
|
|
440
|
+
// does through workItemRefs on the create body.
|
|
441
|
+
if (wired.length) body.workItemRefs = wired.map((item) => ({ id: item.id }));
|
|
271
442
|
|
|
272
443
|
const pr = await c.post<any>(
|
|
273
444
|
pullRequestsPath(repository),
|
|
@@ -275,15 +446,27 @@ export async function prCreateOrUpdate(args: PrCreateOrUpdateArgs): Promise<stri
|
|
|
275
446
|
{ 'api-version': '7.1' }
|
|
276
447
|
);
|
|
277
448
|
|
|
449
|
+
// P2: the tool moves the linked items itself at raise — planning items to
|
|
450
|
+
// Awaiting Approval, loop items to Updating Docs. Implementation PRs also
|
|
451
|
+
// arm transitionWorkItems so ADO lands the linked items on Done at merge.
|
|
452
|
+
await moveWiredItems(c, kind, wired);
|
|
453
|
+
|
|
454
|
+
// P4: attach the mandatory Feature attachment now that the PR id exists.
|
|
455
|
+
if (planningAttachment) {
|
|
456
|
+
await uploadAttachment(c, planningAttachment.workItemId, planningAttachment.fileName, args.attachmentContent!);
|
|
457
|
+
} else if (implAttachmentTarget !== undefined) {
|
|
458
|
+
await uploadAttachment(c, implAttachmentTarget, `impl-report-pr${pr.pullRequestId}.md`, args.attachmentContent!);
|
|
459
|
+
}
|
|
460
|
+
|
|
278
461
|
if (arm) {
|
|
279
462
|
// The PR we just created was created BY us, so its `createdBy` is the
|
|
280
463
|
// authenticated identity — no extra lookup needed to attribute the arming.
|
|
281
464
|
await c.patch<any>(
|
|
282
465
|
pullRequestsPath(repository, pr.pullRequestId),
|
|
283
|
-
autoCompleteBody(pr.createdBy?.id ?? ''),
|
|
466
|
+
autoCompleteBody(pr.createdBy?.id ?? '', kind === 'implementation'),
|
|
284
467
|
{ 'api-version': '7.1' }
|
|
285
468
|
);
|
|
286
469
|
}
|
|
287
470
|
|
|
288
|
-
return JSON.stringify(prCreateResult(repository, pr.pullRequestId, pr.title, pr.status, arm), null, 2);
|
|
471
|
+
return JSON.stringify(prCreateResult(repository, pr.pullRequestId, pr.title, pr.status, arm, kind, wired.map((item) => item.id)), null, 2);
|
|
289
472
|
}
|