@aivi/tracker-github 0.0.0-stage → 1.0.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The worker run's **progress stream**: while a run works, its OpenCode
3
+ * session is mirrored into the ticket through the platform's transient
4
+ * progress surface (`Platform.progress`) — GitHub edits the run's status
5
+ * comment in place, the one line that says what the worker is doing right
6
+ * now (ruled 2026-10-02: ephemeral by contract — a replaced line must not
7
+ * linger, and GitHub has no ephemeral activity, so one comment edited is the
8
+ * honest equivalent). The reducer and the renderer are the host's own
9
+ * channel machinery, so the worker's line and a chat placeholder say the
10
+ * same thing in the same words.
11
+ *
12
+ * No polling and no interval: besides the throttle's trailing edge, the
13
+ * only timer waits for the next instant the rendered line changes on its
14
+ * own (the long-turn suffix, the idle notice, an elapsed refresh). The
15
+ * follower pauses while a question awaits a person — nothing happens then —
16
+ * and resumes when the answer lands.
17
+ */
18
+ import { DEFAULT_CLOCK, nextRenderChange, reduceProgress, renderProgress, startProgress, } from '@aivi/host';
19
+ export class RunProgress {
20
+ platform;
21
+ conversation;
22
+ sessionId;
23
+ mode;
24
+ events;
25
+ log;
26
+ throttleMs;
27
+ clock;
28
+ state;
29
+ shown;
30
+ lastPostAt;
31
+ unwatch;
32
+ trailing;
33
+ change;
34
+ inflight = Promise.resolve();
35
+ following = false;
36
+ constructor(platform, conversation, sessionId, options) {
37
+ this.platform = platform;
38
+ this.conversation = conversation;
39
+ this.sessionId = sessionId;
40
+ this.mode = options.mode;
41
+ this.events = options.events;
42
+ this.log = options.log;
43
+ this.throttleMs = options.throttleMs ?? 2_000;
44
+ this.clock = { ...DEFAULT_CLOCK, ...options.clock };
45
+ const now = Date.now();
46
+ this.state = startProgress(now);
47
+ this.shown = renderProgress(this.state, this.mode, now, this.clock);
48
+ this.lastPostAt = 0;
49
+ }
50
+ /** Follow the session: watch and arm. Idempotent; a stopped follower
51
+ * resumes with its history — the elapsed line keeps counting, because
52
+ * the worker did. */
53
+ start() {
54
+ if (this.following || !this.platform.progress)
55
+ return;
56
+ this.following = true;
57
+ this.unwatch = this.events.watch(this.sessionId, event => {
58
+ this.state = reduceProgress(this.state, event, Date.now());
59
+ this.schedule();
60
+ });
61
+ this.arm();
62
+ }
63
+ /** Say nothing more: unwatch, disarm, let the in-flight edit land. */
64
+ async stop() {
65
+ this.following = false;
66
+ this.unwatch?.();
67
+ this.unwatch = undefined;
68
+ clearTimeout(this.trailing);
69
+ this.trailing = undefined;
70
+ clearTimeout(this.change);
71
+ this.change = undefined;
72
+ await this.inflight;
73
+ }
74
+ /** Wait for the next instant the line changes by itself. */
75
+ arm() {
76
+ clearTimeout(this.change);
77
+ if (!this.following)
78
+ return;
79
+ const now = Date.now();
80
+ const at = nextRenderChange(this.state, now, this.clock);
81
+ this.change = setTimeout(() => this.schedule(), Math.max(0, at - now)).unref();
82
+ }
83
+ schedule() {
84
+ if (!this.following || this.trailing)
85
+ return;
86
+ const wait = this.lastPostAt + this.throttleMs - Date.now();
87
+ if (wait <= 0) {
88
+ this.flush();
89
+ return;
90
+ }
91
+ this.trailing = setTimeout(() => {
92
+ this.trailing = undefined;
93
+ this.flush();
94
+ }, wait).unref();
95
+ }
96
+ /** One edit per visible change — and only when the text actually
97
+ * changed, so the stream says each moment once. A running tool is
98
+ * offered as a tool; a `status`-mode follower keeps it inside the line. */
99
+ flush() {
100
+ if (!this.following)
101
+ return;
102
+ this.arm();
103
+ const now = Date.now();
104
+ const text = renderProgress(this.state, this.mode, now, this.clock);
105
+ if (text === this.shown)
106
+ return;
107
+ this.shown = text;
108
+ this.lastPostAt = now;
109
+ const running = this.mode === 'tools' ? this.state.tools.findLast(t => t.state === 'running' && !t.pending) : undefined;
110
+ const head = text.split('\n')[0];
111
+ const line = {
112
+ text: head,
113
+ ...(running ? { tool: { name: running.name, ...(running.detail ? { detail: running.detail } : {}) } } : {}),
114
+ };
115
+ this.inflight = this.inflight
116
+ .then(async () => {
117
+ if (this.following)
118
+ await this.platform.progress?.(this.conversation, line);
119
+ })
120
+ .catch(error => this.log.warn('progress.edit.failed', { error }));
121
+ }
122
+ }
@@ -0,0 +1,125 @@
1
+ import { type Logger, type ProjectLaneInput } from '@aivi/core';
2
+ import { type GitHubApp } from '@aivi/github';
3
+ import { type ProjectContributor } from '@aivi/plugin';
4
+ /** One Status option, as GitHub answers it: an id, a name, and nothing
5
+ * else. **GitHub says nothing about what an option means**:
6
+ * `ProjectV2SingleSelectFieldOption` carries
7
+ * `color, description, descriptionHTML, id, name, nameHTML` and no state
8
+ * flag — asking for `status` fails the whole read, because GraphQL rejects a
9
+ * query naming a field that does not exist. What a Status change does to an
10
+ * issue is the board's workflows, and a workflow's rule is not in the API
11
+ * either (`ProjectV2Workflow` answers id, name, number, enabled and dates).
12
+ * So the person decides what each option is: see `askLanes`. The org node id
13
+ * (on the board) is what a field creation is addressed to. */
14
+ export interface SetupBoardOption {
15
+ id: string;
16
+ name: string;
17
+ }
18
+ export interface SetupBoard {
19
+ id: string;
20
+ /** The board's number in the org — what the `tracker-github` section stores. */
21
+ number: number;
22
+ orgId: string;
23
+ title: string;
24
+ statusOptions: SetupBoardOption[];
25
+ /** The `Status` field's own id — the probe mutates it directly. */
26
+ statusFieldId: string;
27
+ agentFieldId?: string;
28
+ /** The `Agent` field's options; empty when the board has no such field yet. */
29
+ agentOptions: {
30
+ id: string;
31
+ name: string;
32
+ }[];
33
+ }
34
+ /** The board probe's test card: what the wait must hear back (`asked` names
35
+ * the signals — probe.ts's `BOARD_SIGNALS` words), and everything cleanup
36
+ * needs. `statusSkipped` is a board with a single Status option, where no
37
+ * move can be shown: said as skipped, never as a failure. */
38
+ export interface ProbeHandles {
39
+ ref: string;
40
+ number: number;
41
+ /** The issue's node id: board deliveries name no number, only this. */
42
+ nodeId: string;
43
+ itemId: string;
44
+ projectId: string;
45
+ agentFieldId: string;
46
+ commentId: number;
47
+ asked: string[];
48
+ statusSkipped: boolean;
49
+ }
50
+ /** The wizard's crossing to GitHub, injectable like Linear's `MakeTeamsClient`:
51
+ * the questions, their order, and the written config are the unit under test;
52
+ * the GraphQL round trips are the live gate's. `delegate` is the `Agent`
53
+ * option name the app delegates as — its own login (D5). */
54
+ export interface GithubSetupClient {
55
+ readonly delegate: string;
56
+ readBoard(boardNumber: number): Promise<SetupBoard>;
57
+ createAgentField(board: SetupBoard, delegate: string): Promise<void>;
58
+ /** Append the `Agent` field to every view that does not show it — the
59
+ * mutation replaces, so each view is read first and written back whole.
60
+ * Answers the view names it changed; idempotent. */
61
+ showAgentOnViews(board: SetupBoard, agentFieldId: string): Promise<string[]>;
62
+ repositories(): Promise<{
63
+ id: string;
64
+ }[]>;
65
+ ensureLabel(repo: string, label: string): Promise<void>;
66
+ /** Pay the tracker's mutations on one throwaway issue: open, add to board,
67
+ * move Status, set Agent, comment, react — everything the tracker will
68
+ * ever do, and everything the diary must echo back (the reaction aside:
69
+ * paid and read back, no delivery subscribes). */
70
+ openProbe(repo: string, board: SetupBoard): Promise<ProbeHandles>;
71
+ /** Lay the test card to rest: Agent cleared, item archived, own reaction
72
+ * removed, comment deleted — then close, **unless GitHub's own workflow
73
+ * already closed it**: answers one line saying how the card rests, in
74
+ * GitHub's real terms. GitHub has no API to delete an issue. */
75
+ closeProbe(handles: ProbeHandles): Promise<string>;
76
+ }
77
+ export type MakeSetupClient = (log: Logger) => Promise<GithubSetupClient>;
78
+ /** The production crossing: the home's app (D3 — the wizard and the module
79
+ * speak through one credential), read through the tracker's own client.
80
+ * Exported for the test that scripts GitHub's answers and proves the probe's
81
+ * own choices — which Status option it pays, the reaction it reads back, and
82
+ * whose close it reports. */
83
+ export declare class BoardSetupClient implements GithubSetupClient {
84
+ private readonly app;
85
+ private readonly tracker;
86
+ constructor(app: GitHubApp);
87
+ get delegate(): string;
88
+ readBoard(boardNumber: number): Promise<SetupBoard>;
89
+ createAgentField(board: SetupBoard, delegate: string): Promise<void>;
90
+ showAgentOnViews(board: SetupBoard, agentFieldId: string): Promise<string[]>;
91
+ repositories(): Promise<{
92
+ id: string;
93
+ }[]>;
94
+ ensureLabel(repo: string, label: string): Promise<void>;
95
+ openProbe(repo: string, board: SetupBoard): Promise<ProbeHandles>;
96
+ closeProbe(handles: ProbeHandles): Promise<string>;
97
+ }
98
+ /** The lanes the queue question may offer (ruled 2026-10-02, mirrored from
99
+ * Linear's wizard): a queue lane must feed a worker lane, so a lane whose next
100
+ * works nobody is no candidate, the last lane feeds nothing at all, and a lane
101
+ * that has an agent is never an option — a queue is where people wait for a
102
+ * worker, not a worker lane itself. A **human** lane is a legal queue: that is
103
+ * what "used as a queue" means in the answer's own words. */
104
+ export declare function queueCandidates(lanes: ProjectLaneInput[]): ProjectLaneInput[];
105
+ /** Mark the chosen queue lane. The queue answer is the wizard's later word, so
106
+ * a lane that just chose an agent gives it up — and says so out loud. The
107
+ * returned lines are what the caller logs. */
108
+ export declare function markQueue(lanes: ProjectLaneInput[], chosen: string): string[];
109
+ /** The project name a board title suggests: GitHub's `My Board` becomes aivi's
110
+ * `my-board`, the shape a project directory takes (the forge's rule for repo
111
+ * names, turned on a title). Anything the id rule does not allow becomes a
112
+ * dash, and a name that no longer starts with a letter keeps one. */
113
+ export declare function boardSuggestion(title: string): string;
114
+ /** The wizard, in its order: the health gate as a choice, the board, the
115
+ * delegate surface and its view visibility, the repositories, the project's
116
+ * name, what kind of lane each Status option is (agent, human, or none),
117
+ * the queue over the no-agent lanes,
118
+ * the human label in every mapped repository — and, when a live aivi was
119
+ * there to watch, the board probe last: it pays real mutations, so it comes
120
+ * after everything it tests. Each step is its own question and its own
121
+ * honest failure; the whole is the ruling. */
122
+ declare function makeContributor(makeClient?: MakeSetupClient): ProjectContributor;
123
+ export declare const contributor: ProjectContributor;
124
+ export { makeContributor };
125
+ export default contributor;