@h-rig/server 0.0.6-alpha.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.
- package/README.md +14 -0
- package/dist/src/bootstrap.js +161 -0
- package/dist/src/index.js +13153 -0
- package/dist/src/inspector/agent-runtime.js +1077 -0
- package/dist/src/inspector/analysis.js +41 -0
- package/dist/src/inspector/discovery.js +137 -0
- package/dist/src/inspector/journal.js +518 -0
- package/dist/src/inspector/mission.js +562 -0
- package/dist/src/inspector/prompt.js +97 -0
- package/dist/src/inspector/provider-session.js +65 -0
- package/dist/src/inspector/reconcile.js +118 -0
- package/dist/src/inspector/review.js +13 -0
- package/dist/src/inspector/service.js +1759 -0
- package/dist/src/inspector/skills.js +155 -0
- package/dist/src/inspector/tools.js +1592 -0
- package/dist/src/inspector/types.js +1 -0
- package/dist/src/inspector/upstream-sync.js +479 -0
- package/dist/src/orchestration.js +402 -0
- package/dist/src/remote.js +123 -0
- package/dist/src/scheduler.js +84 -0
- package/dist/src/server-helpers/broadcasters.js +161 -0
- package/dist/src/server-helpers/conversation-snapshot.js +382 -0
- package/dist/src/server-helpers/event-emitter.js +41 -0
- package/dist/src/server-helpers/github-auth-store.js +155 -0
- package/dist/src/server-helpers/github-credentials.js +38 -0
- package/dist/src/server-helpers/github-project-status-sync.js +196 -0
- package/dist/src/server-helpers/github-projects.js +147 -0
- package/dist/src/server-helpers/github-reconciler.js +89 -0
- package/dist/src/server-helpers/http-router.js +3781 -0
- package/dist/src/server-helpers/http-utils.js +135 -0
- package/dist/src/server-helpers/inspector-agent-lifecycle.js +104 -0
- package/dist/src/server-helpers/inspector-jobs.js +4145 -0
- package/dist/src/server-helpers/issue-analysis.js +362 -0
- package/dist/src/server-helpers/normalizers.js +31 -0
- package/dist/src/server-helpers/notifications.js +96 -0
- package/dist/src/server-helpers/orchestration-ops.js +287 -0
- package/dist/src/server-helpers/orchestration.js +39 -0
- package/dist/src/server-helpers/plugin-host-cache.js +86 -0
- package/dist/src/server-helpers/project-fs-ops.js +194 -0
- package/dist/src/server-helpers/project-registry.js +124 -0
- package/dist/src/server-helpers/queue-state.js +78 -0
- package/dist/src/server-helpers/remote-checkout.js +140 -0
- package/dist/src/server-helpers/remote-snapshots.js +119 -0
- package/dist/src/server-helpers/run-io.js +262 -0
- package/dist/src/server-helpers/run-mutations.js +1784 -0
- package/dist/src/server-helpers/run-steering.js +176 -0
- package/dist/src/server-helpers/run-writers.js +75 -0
- package/dist/src/server-helpers/server-paths.js +27 -0
- package/dist/src/server-helpers/snapshot-orchestrator.js +832 -0
- package/dist/src/server-helpers/snapshot-service.js +1143 -0
- package/dist/src/server-helpers/summaries.js +126 -0
- package/dist/src/server-helpers/task-config.js +50 -0
- package/dist/src/server-helpers/task-projection.js +98 -0
- package/dist/src/server-helpers/terminal-runtime.js +156 -0
- package/dist/src/server-helpers/terminal-sessions.js +22 -0
- package/dist/src/server-helpers/validation-failure.js +31 -0
- package/dist/src/server-helpers/ws-router.js +1308 -0
- package/dist/src/server.js +12628 -0
- package/dist/src/websocket.js +63 -0
- package/package.json +33 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/server/src/server-helpers/github-auth-store.ts
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { resolve as resolve2 } from "path";
|
|
5
|
+
|
|
6
|
+
// packages/server/src/server-helpers/server-paths.ts
|
|
7
|
+
import { dirname, resolve } from "path";
|
|
8
|
+
import { resolveMonorepoRoot } from "@rig/runtime/control-plane/native/utils";
|
|
9
|
+
function resolveServerAuthorityPaths(projectRoot) {
|
|
10
|
+
const taskWorkspace = process.env.RIG_TASK_WORKSPACE?.trim();
|
|
11
|
+
const explicitStateDir = process.env.RIG_STATE_DIR?.trim();
|
|
12
|
+
const explicitLogsDir = process.env.RIG_LOGS_DIR?.trim();
|
|
13
|
+
const explicitSessionFile = process.env.RIG_SESSION_FILE?.trim();
|
|
14
|
+
const monorepoRoot = resolveMonorepoRoot(projectRoot);
|
|
15
|
+
const stateRoot = taskWorkspace ? resolve(taskWorkspace, ".rig") : explicitStateDir ? dirname(resolve(explicitStateDir)) : explicitLogsDir ? dirname(resolve(explicitLogsDir)) : explicitSessionFile ? dirname(dirname(resolve(explicitSessionFile))) : resolve(monorepoRoot, ".rig");
|
|
16
|
+
const stateDir = explicitStateDir ? resolve(explicitStateDir) : resolve(stateRoot, "state");
|
|
17
|
+
const logsDir = explicitLogsDir ? resolve(explicitLogsDir) : resolve(stateRoot, "logs");
|
|
18
|
+
const sessionFile = explicitSessionFile ? resolve(explicitSessionFile) : resolve(stateRoot, "session", "session.json");
|
|
19
|
+
const artifactsDir = taskWorkspace ? resolve(taskWorkspace, "artifacts") : resolve(monorepoRoot, "artifacts");
|
|
20
|
+
return {
|
|
21
|
+
stateRoot,
|
|
22
|
+
stateDir,
|
|
23
|
+
logsDir,
|
|
24
|
+
controlPlaneEventsFile: resolve(logsDir, "control-plane.events.jsonl"),
|
|
25
|
+
sessionFile,
|
|
26
|
+
artifactsDir
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// packages/server/src/server-helpers/github-auth-store.ts
|
|
31
|
+
function cleanString(value) {
|
|
32
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
33
|
+
}
|
|
34
|
+
function cleanScopes(value) {
|
|
35
|
+
if (!Array.isArray(value))
|
|
36
|
+
return [];
|
|
37
|
+
return value.flatMap((entry) => {
|
|
38
|
+
const clean = cleanString(entry);
|
|
39
|
+
return clean ? [clean] : [];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function readStoredAuth(stateFile) {
|
|
43
|
+
if (!existsSync(stateFile))
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
47
|
+
return {
|
|
48
|
+
...cleanString(parsed.token) ? { token: cleanString(parsed.token) } : {},
|
|
49
|
+
login: cleanString(parsed.login),
|
|
50
|
+
userId: cleanString(parsed.userId),
|
|
51
|
+
scopes: cleanScopes(parsed.scopes),
|
|
52
|
+
selectedRepo: cleanString(parsed.selectedRepo),
|
|
53
|
+
tokenSource: parsed.tokenSource === "oauth-device" || parsed.tokenSource === "manual-token" || parsed.tokenSource === "env" ? parsed.tokenSource : undefined,
|
|
54
|
+
pendingDevice: parsePendingDevice(parsed.pendingDevice),
|
|
55
|
+
updatedAt: cleanString(parsed.updatedAt) ?? undefined
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function parsePendingDevice(value) {
|
|
62
|
+
if (!value || typeof value !== "object")
|
|
63
|
+
return null;
|
|
64
|
+
const record = value;
|
|
65
|
+
const pollId = cleanString(record.pollId);
|
|
66
|
+
const deviceCode = cleanString(record.deviceCode);
|
|
67
|
+
const expiresAt = cleanString(record.expiresAt);
|
|
68
|
+
const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
|
|
69
|
+
if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
|
|
70
|
+
return null;
|
|
71
|
+
return { pollId, deviceCode, expiresAt, intervalSeconds };
|
|
72
|
+
}
|
|
73
|
+
function writeStoredAuth(stateFile, payload) {
|
|
74
|
+
mkdirSync(resolve2(stateFile, ".."), { recursive: true });
|
|
75
|
+
writeFileSync(stateFile, `${JSON.stringify(payload, null, 2)}
|
|
76
|
+
`, { encoding: "utf8", mode: 384 });
|
|
77
|
+
try {
|
|
78
|
+
chmodSync(stateFile, 384);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
function resolveGitHubAuthStateFile(projectRoot) {
|
|
82
|
+
return resolve2(resolveServerAuthorityPaths(projectRoot).stateDir, "github-auth.json");
|
|
83
|
+
}
|
|
84
|
+
function createGitHubAuthStore(projectRoot) {
|
|
85
|
+
const stateFile = resolveGitHubAuthStateFile(projectRoot);
|
|
86
|
+
return {
|
|
87
|
+
stateFile,
|
|
88
|
+
status(options) {
|
|
89
|
+
const stored = readStoredAuth(stateFile);
|
|
90
|
+
const token = cleanString(stored.token);
|
|
91
|
+
return {
|
|
92
|
+
signedIn: Boolean(token),
|
|
93
|
+
login: cleanString(stored.login),
|
|
94
|
+
userId: cleanString(stored.userId),
|
|
95
|
+
scopes: cleanScopes(stored.scopes),
|
|
96
|
+
selectedRepo: cleanString(stored.selectedRepo),
|
|
97
|
+
oauthConfigured: options?.oauthConfigured === true,
|
|
98
|
+
tokenSource: token ? stored.tokenSource ?? "manual-token" : null
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
readToken() {
|
|
102
|
+
return cleanString(readStoredAuth(stateFile).token);
|
|
103
|
+
},
|
|
104
|
+
saveToken(input) {
|
|
105
|
+
const previous = readStoredAuth(stateFile);
|
|
106
|
+
writeStoredAuth(stateFile, {
|
|
107
|
+
...previous,
|
|
108
|
+
token: input.token,
|
|
109
|
+
tokenSource: input.tokenSource,
|
|
110
|
+
login: input.login ?? null,
|
|
111
|
+
userId: input.userId ?? null,
|
|
112
|
+
scopes: input.scopes ?? [],
|
|
113
|
+
selectedRepo: input.selectedRepo ?? previous.selectedRepo ?? null,
|
|
114
|
+
pendingDevice: null,
|
|
115
|
+
updatedAt: new Date().toISOString()
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
savePendingDevice(input) {
|
|
119
|
+
const previous = readStoredAuth(stateFile);
|
|
120
|
+
writeStoredAuth(stateFile, {
|
|
121
|
+
...previous,
|
|
122
|
+
pendingDevice: input,
|
|
123
|
+
updatedAt: new Date().toISOString()
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
saveSelectedRepo(selectedRepo) {
|
|
127
|
+
const previous = readStoredAuth(stateFile);
|
|
128
|
+
writeStoredAuth(stateFile, {
|
|
129
|
+
...previous,
|
|
130
|
+
selectedRepo: selectedRepo ?? null,
|
|
131
|
+
updatedAt: new Date().toISOString()
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
readPendingDevice(pollId) {
|
|
135
|
+
const pending = readStoredAuth(stateFile).pendingDevice ?? null;
|
|
136
|
+
if (!pending || pending.pollId !== pollId)
|
|
137
|
+
return null;
|
|
138
|
+
if (Date.parse(pending.expiresAt) <= Date.now())
|
|
139
|
+
return null;
|
|
140
|
+
return pending;
|
|
141
|
+
},
|
|
142
|
+
clearPendingDevice() {
|
|
143
|
+
const previous = readStoredAuth(stateFile);
|
|
144
|
+
writeStoredAuth(stateFile, {
|
|
145
|
+
...previous,
|
|
146
|
+
pendingDevice: null,
|
|
147
|
+
updatedAt: new Date().toISOString()
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
resolveGitHubAuthStateFile,
|
|
154
|
+
createGitHubAuthStore
|
|
155
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/server/src/server-helpers/github-credentials.ts
|
|
3
|
+
function selectedRepoTokenKey(input) {
|
|
4
|
+
return `user:${input.userId}|repo:${input.owner}/${input.repo}|workspace:${input.workspaceId}`;
|
|
5
|
+
}
|
|
6
|
+
function cleanToken(value) {
|
|
7
|
+
const trimmed = value?.trim() ?? "";
|
|
8
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
9
|
+
}
|
|
10
|
+
function createGitHubCredentialProvider(options = {}) {
|
|
11
|
+
const sessionTokens = options.sessionTokens ?? {};
|
|
12
|
+
const hostToken = cleanToken(options.hostToken ?? process.env.GH_TOKEN ?? null);
|
|
13
|
+
return {
|
|
14
|
+
async resolveGitHubToken(input) {
|
|
15
|
+
const owner = input.owner.trim();
|
|
16
|
+
const repo = input.repo.trim();
|
|
17
|
+
const workspaceId = input.workspaceId.trim();
|
|
18
|
+
const userId = input.userId?.trim() ?? "";
|
|
19
|
+
if (input.purpose === "selected-repo") {
|
|
20
|
+
if (!owner || !repo || !workspaceId || !userId) {
|
|
21
|
+
throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
|
|
22
|
+
}
|
|
23
|
+
const token = cleanToken(sessionTokens[selectedRepoTokenKey({ owner, repo, workspaceId, userId })]);
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
|
|
26
|
+
}
|
|
27
|
+
return { token, source: "signed-in-user" };
|
|
28
|
+
}
|
|
29
|
+
if (hostToken) {
|
|
30
|
+
return { token: hostToken, source: "host-admin-fallback" };
|
|
31
|
+
}
|
|
32
|
+
throw new Error("No host GitHub token is configured for the explicit admin fallback operation.");
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
createGitHubCredentialProvider
|
|
38
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/server/src/server-helpers/github-projects.ts
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
5
|
+
}
|
|
6
|
+
function asString(value) {
|
|
7
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
async function defaultGraphQLFetch(query, variables, token) {
|
|
10
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": "application/json",
|
|
14
|
+
authorization: `Bearer ${token}`,
|
|
15
|
+
accept: "application/vnd.github+json"
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ query, variables })
|
|
18
|
+
});
|
|
19
|
+
const json = await response.json().catch(() => ({}));
|
|
20
|
+
if (!response.ok || json.errors) {
|
|
21
|
+
throw new Error(`GitHub Projects GraphQL request failed: ${JSON.stringify(json.errors ?? { status: response.status })}`);
|
|
22
|
+
}
|
|
23
|
+
return json.data;
|
|
24
|
+
}
|
|
25
|
+
async function resolveProjectStatusField(input) {
|
|
26
|
+
const query = `
|
|
27
|
+
query RigProjectStatusField($projectId: ID!) {
|
|
28
|
+
node(id: $projectId) {
|
|
29
|
+
... on ProjectV2 {
|
|
30
|
+
fields(first: 50) {
|
|
31
|
+
nodes {
|
|
32
|
+
... on ProjectV2FieldCommon { id name }
|
|
33
|
+
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
41
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId }, input.token);
|
|
42
|
+
const fields = asRecord(asRecord(asRecord(data)?.node)?.fields)?.nodes;
|
|
43
|
+
for (const node of Array.isArray(fields) ? fields : []) {
|
|
44
|
+
const record = asRecord(node);
|
|
45
|
+
if (asString(record?.name)?.toLowerCase() !== "status")
|
|
46
|
+
continue;
|
|
47
|
+
const id = asString(record?.id);
|
|
48
|
+
if (!id)
|
|
49
|
+
continue;
|
|
50
|
+
const options = Array.isArray(record?.options) ? record.options.flatMap((option) => {
|
|
51
|
+
const optionRecord = asRecord(option);
|
|
52
|
+
const optionId = asString(optionRecord?.id);
|
|
53
|
+
const name = asString(optionRecord?.name);
|
|
54
|
+
return optionId && name ? [{ id: optionId, name }] : [];
|
|
55
|
+
}) : [];
|
|
56
|
+
return { id, name: "Status", options };
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`GitHub Project ${input.projectId} does not expose a Status single-select field.`);
|
|
59
|
+
}
|
|
60
|
+
async function ensureIssueProjectItem(input) {
|
|
61
|
+
const query = `
|
|
62
|
+
query RigFindProjectIssueItem($projectId: ID!, $issueNodeId: ID!) {
|
|
63
|
+
node(id: $projectId) {
|
|
64
|
+
... on ProjectV2 {
|
|
65
|
+
items(first: 100) { nodes { id content { ... on Issue { id } } } }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
71
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId, issueNodeId: input.issueNodeId }, input.token);
|
|
72
|
+
const nodes = asRecord(asRecord(asRecord(data)?.node)?.items)?.nodes;
|
|
73
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
74
|
+
const record = asRecord(node);
|
|
75
|
+
const content = asRecord(record?.content);
|
|
76
|
+
if (asString(content?.id) === input.issueNodeId) {
|
|
77
|
+
const id2 = asString(record?.id);
|
|
78
|
+
if (id2)
|
|
79
|
+
return { id: id2, created: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const mutation = `
|
|
83
|
+
mutation RigAddIssueToProject($projectId: ID!, $contentId: ID!) {
|
|
84
|
+
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
const created = await fetchGraphQL(mutation, { projectId: input.projectId, contentId: input.issueNodeId }, input.token);
|
|
88
|
+
const addResult = asRecord(asRecord(created)?.addProjectV2ItemById);
|
|
89
|
+
const id = asString(asRecord(addResult?.item)?.id);
|
|
90
|
+
if (!id)
|
|
91
|
+
throw new Error("GitHub Project item creation did not return an item id.");
|
|
92
|
+
return { id, created: true };
|
|
93
|
+
}
|
|
94
|
+
async function updateIssueProjectStatus(input) {
|
|
95
|
+
const mutation = `
|
|
96
|
+
mutation RigUpdateProjectStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
97
|
+
updateProjectV2ItemFieldValue(input: {
|
|
98
|
+
projectId: $projectId,
|
|
99
|
+
itemId: $itemId,
|
|
100
|
+
fieldId: $fieldId,
|
|
101
|
+
value: { singleSelectOptionId: $optionId }
|
|
102
|
+
}) { projectV2Item { id } }
|
|
103
|
+
}
|
|
104
|
+
`;
|
|
105
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
106
|
+
await fetchGraphQL(mutation, {
|
|
107
|
+
projectId: input.projectId,
|
|
108
|
+
itemId: input.itemId,
|
|
109
|
+
fieldId: input.fieldId,
|
|
110
|
+
optionId: input.optionId
|
|
111
|
+
}, input.token);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// packages/server/src/server-helpers/github-project-status-sync.ts
|
|
115
|
+
var DEFAULT_PROJECT_STATUSES = {
|
|
116
|
+
running: "In Progress",
|
|
117
|
+
prOpen: "In Review",
|
|
118
|
+
ciFixing: "In Review",
|
|
119
|
+
done: "Done",
|
|
120
|
+
needsAttention: "Needs Attention"
|
|
121
|
+
};
|
|
122
|
+
function lifecycleStatusForTaskStatus(status) {
|
|
123
|
+
const normalized = status?.trim().toLowerCase().replace(/[-\s]+/g, "_");
|
|
124
|
+
if (!normalized)
|
|
125
|
+
return null;
|
|
126
|
+
if (normalized === "closed" || normalized === "done")
|
|
127
|
+
return "done";
|
|
128
|
+
if (normalized === "under_review" || normalized === "review" || normalized === "pr_open")
|
|
129
|
+
return "prOpen";
|
|
130
|
+
if (normalized === "ci_fixing" || normalized === "fixing")
|
|
131
|
+
return "ciFixing";
|
|
132
|
+
if (normalized === "failed" || normalized === "needs_attention" || normalized === "blocked")
|
|
133
|
+
return "needsAttention";
|
|
134
|
+
if (normalized === "in_progress" || normalized === "running" || normalized === "ready" || normalized === "open")
|
|
135
|
+
return "running";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function cleanString(value) {
|
|
139
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
140
|
+
}
|
|
141
|
+
function projectConfigFrom(config) {
|
|
142
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
143
|
+
return null;
|
|
144
|
+
const root = config;
|
|
145
|
+
return root.github?.projects ?? null;
|
|
146
|
+
}
|
|
147
|
+
async function syncGitHubProjectStatusForTaskUpdate(input) {
|
|
148
|
+
const projects = projectConfigFrom(input.config);
|
|
149
|
+
if (!projects?.enabled)
|
|
150
|
+
return { synced: false, reason: "project-sync-disabled" };
|
|
151
|
+
const projectId = cleanString(projects.projectId);
|
|
152
|
+
if (!projectId)
|
|
153
|
+
return { synced: false, reason: "missing-project-id" };
|
|
154
|
+
const token = cleanString(input.token);
|
|
155
|
+
if (!token)
|
|
156
|
+
return { synced: false, reason: "missing-token" };
|
|
157
|
+
const lifecycleStatus = lifecycleStatusForTaskStatus(input.status);
|
|
158
|
+
if (!lifecycleStatus)
|
|
159
|
+
return { synced: false, reason: "missing-status" };
|
|
160
|
+
const issueNodeId = cleanString(input.issueNodeId);
|
|
161
|
+
if (!issueNodeId)
|
|
162
|
+
return { synced: false, reason: "missing-issue-node-id" };
|
|
163
|
+
const projectStatus = cleanString(projects.statuses?.[lifecycleStatus]) ?? DEFAULT_PROJECT_STATUSES[lifecycleStatus];
|
|
164
|
+
const field = await resolveProjectStatusField({ projectId, token, fetchGraphQL: input.fetchGraphQL });
|
|
165
|
+
const option = field.options.find((candidate) => candidate.name.toLowerCase() === projectStatus.toLowerCase() || candidate.id === projectStatus);
|
|
166
|
+
if (!option)
|
|
167
|
+
return { synced: false, reason: "missing-project-status-option" };
|
|
168
|
+
const item = await ensureIssueProjectItem({ projectId, issueNodeId, token, fetchGraphQL: input.fetchGraphQL });
|
|
169
|
+
await updateIssueProjectStatus({
|
|
170
|
+
projectId,
|
|
171
|
+
itemId: item.id,
|
|
172
|
+
fieldId: cleanString(projects.statusFieldId) ?? field.id,
|
|
173
|
+
optionId: option.id,
|
|
174
|
+
token,
|
|
175
|
+
fetchGraphQL: input.fetchGraphQL
|
|
176
|
+
});
|
|
177
|
+
return { synced: true, lifecycleStatus, projectStatus, itemId: item.id };
|
|
178
|
+
}
|
|
179
|
+
function extractGitHubIssueNodeId(task) {
|
|
180
|
+
if (!task || typeof task !== "object" || Array.isArray(task))
|
|
181
|
+
return null;
|
|
182
|
+
const record = task;
|
|
183
|
+
const direct = cleanString(record.issueNodeId) ?? cleanString(record.nodeId) ?? cleanString(record.node_id);
|
|
184
|
+
if (direct)
|
|
185
|
+
return direct;
|
|
186
|
+
const raw = record.raw;
|
|
187
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
188
|
+
return null;
|
|
189
|
+
const rawRecord = raw;
|
|
190
|
+
return cleanString(rawRecord.id) ?? cleanString(rawRecord.nodeId) ?? cleanString(rawRecord.node_id);
|
|
191
|
+
}
|
|
192
|
+
export {
|
|
193
|
+
syncGitHubProjectStatusForTaskUpdate,
|
|
194
|
+
lifecycleStatusForTaskStatus,
|
|
195
|
+
extractGitHubIssueNodeId
|
|
196
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/server/src/server-helpers/github-projects.ts
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
5
|
+
}
|
|
6
|
+
function asString(value) {
|
|
7
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
function asNumber(value) {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
async function defaultGraphQLFetch(query, variables, token) {
|
|
13
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
"content-type": "application/json",
|
|
17
|
+
authorization: `Bearer ${token}`,
|
|
18
|
+
accept: "application/vnd.github+json"
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({ query, variables })
|
|
21
|
+
});
|
|
22
|
+
const json = await response.json().catch(() => ({}));
|
|
23
|
+
if (!response.ok || json.errors) {
|
|
24
|
+
throw new Error(`GitHub Projects GraphQL request failed: ${JSON.stringify(json.errors ?? { status: response.status })}`);
|
|
25
|
+
}
|
|
26
|
+
return json.data;
|
|
27
|
+
}
|
|
28
|
+
function projectNodesFrom(data) {
|
|
29
|
+
const root = asRecord(data);
|
|
30
|
+
const owner = asRecord(root?.organization) ?? asRecord(root?.user);
|
|
31
|
+
const projects = asRecord(owner?.projectsV2);
|
|
32
|
+
const nodes = projects?.nodes;
|
|
33
|
+
return Array.isArray(nodes) ? nodes : [];
|
|
34
|
+
}
|
|
35
|
+
async function listGitHubProjects(input) {
|
|
36
|
+
const query = `
|
|
37
|
+
query RigListProjects($owner: String!, $first: Int!) {
|
|
38
|
+
organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
|
|
39
|
+
user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
43
|
+
const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
|
|
44
|
+
return projectNodesFrom(data).flatMap((node) => {
|
|
45
|
+
const record = asRecord(node);
|
|
46
|
+
const id = asString(record?.id);
|
|
47
|
+
const number = asNumber(record?.number);
|
|
48
|
+
const title = asString(record?.title);
|
|
49
|
+
if (!id || number === undefined || !title)
|
|
50
|
+
return [];
|
|
51
|
+
return [{ id, number, title, ...asString(record?.url) ? { url: asString(record?.url) } : {} }];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function resolveProjectStatusField(input) {
|
|
55
|
+
const query = `
|
|
56
|
+
query RigProjectStatusField($projectId: ID!) {
|
|
57
|
+
node(id: $projectId) {
|
|
58
|
+
... on ProjectV2 {
|
|
59
|
+
fields(first: 50) {
|
|
60
|
+
nodes {
|
|
61
|
+
... on ProjectV2FieldCommon { id name }
|
|
62
|
+
... on ProjectV2SingleSelectField { id name options { id name } }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
70
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId }, input.token);
|
|
71
|
+
const fields = asRecord(asRecord(asRecord(data)?.node)?.fields)?.nodes;
|
|
72
|
+
for (const node of Array.isArray(fields) ? fields : []) {
|
|
73
|
+
const record = asRecord(node);
|
|
74
|
+
if (asString(record?.name)?.toLowerCase() !== "status")
|
|
75
|
+
continue;
|
|
76
|
+
const id = asString(record?.id);
|
|
77
|
+
if (!id)
|
|
78
|
+
continue;
|
|
79
|
+
const options = Array.isArray(record?.options) ? record.options.flatMap((option) => {
|
|
80
|
+
const optionRecord = asRecord(option);
|
|
81
|
+
const optionId = asString(optionRecord?.id);
|
|
82
|
+
const name = asString(optionRecord?.name);
|
|
83
|
+
return optionId && name ? [{ id: optionId, name }] : [];
|
|
84
|
+
}) : [];
|
|
85
|
+
return { id, name: "Status", options };
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`GitHub Project ${input.projectId} does not expose a Status single-select field.`);
|
|
88
|
+
}
|
|
89
|
+
async function ensureIssueProjectItem(input) {
|
|
90
|
+
const query = `
|
|
91
|
+
query RigFindProjectIssueItem($projectId: ID!, $issueNodeId: ID!) {
|
|
92
|
+
node(id: $projectId) {
|
|
93
|
+
... on ProjectV2 {
|
|
94
|
+
items(first: 100) { nodes { id content { ... on Issue { id } } } }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
100
|
+
const data = await fetchGraphQL(query, { projectId: input.projectId, issueNodeId: input.issueNodeId }, input.token);
|
|
101
|
+
const nodes = asRecord(asRecord(asRecord(data)?.node)?.items)?.nodes;
|
|
102
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
103
|
+
const record = asRecord(node);
|
|
104
|
+
const content = asRecord(record?.content);
|
|
105
|
+
if (asString(content?.id) === input.issueNodeId) {
|
|
106
|
+
const id2 = asString(record?.id);
|
|
107
|
+
if (id2)
|
|
108
|
+
return { id: id2, created: false };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const mutation = `
|
|
112
|
+
mutation RigAddIssueToProject($projectId: ID!, $contentId: ID!) {
|
|
113
|
+
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
const created = await fetchGraphQL(mutation, { projectId: input.projectId, contentId: input.issueNodeId }, input.token);
|
|
117
|
+
const addResult = asRecord(asRecord(created)?.addProjectV2ItemById);
|
|
118
|
+
const id = asString(asRecord(addResult?.item)?.id);
|
|
119
|
+
if (!id)
|
|
120
|
+
throw new Error("GitHub Project item creation did not return an item id.");
|
|
121
|
+
return { id, created: true };
|
|
122
|
+
}
|
|
123
|
+
async function updateIssueProjectStatus(input) {
|
|
124
|
+
const mutation = `
|
|
125
|
+
mutation RigUpdateProjectStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
126
|
+
updateProjectV2ItemFieldValue(input: {
|
|
127
|
+
projectId: $projectId,
|
|
128
|
+
itemId: $itemId,
|
|
129
|
+
fieldId: $fieldId,
|
|
130
|
+
value: { singleSelectOptionId: $optionId }
|
|
131
|
+
}) { projectV2Item { id } }
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
|
|
135
|
+
await fetchGraphQL(mutation, {
|
|
136
|
+
projectId: input.projectId,
|
|
137
|
+
itemId: input.itemId,
|
|
138
|
+
fieldId: input.fieldId,
|
|
139
|
+
optionId: input.optionId
|
|
140
|
+
}, input.token);
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
updateIssueProjectStatus,
|
|
144
|
+
resolveProjectStatusField,
|
|
145
|
+
listGitHubProjects,
|
|
146
|
+
ensureIssueProjectItem
|
|
147
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/server/src/server-helpers/task-projection.ts
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
function projectionPath(projectRoot) {
|
|
6
|
+
return resolve(projectRoot, ".rig", "state", "task-projection.json");
|
|
7
|
+
}
|
|
8
|
+
function stateDir(projectRoot) {
|
|
9
|
+
return resolve(projectRoot, ".rig", "state");
|
|
10
|
+
}
|
|
11
|
+
function cloneTask(task) {
|
|
12
|
+
return { ...task };
|
|
13
|
+
}
|
|
14
|
+
function writeTaskProjection(projectRoot, input) {
|
|
15
|
+
const activeByTask = new Map((input.activeRuns ?? []).map((run) => [run.taskId, run]));
|
|
16
|
+
const tasks = input.tasks.map((task) => {
|
|
17
|
+
const projected = cloneTask(task);
|
|
18
|
+
const active = activeByTask.get(task.id);
|
|
19
|
+
if (active) {
|
|
20
|
+
projected.status = "in_progress";
|
|
21
|
+
projected.activeRun = {
|
|
22
|
+
runId: active.runId,
|
|
23
|
+
...active.status ? { status: active.status } : {},
|
|
24
|
+
...active.stage ? { stage: active.stage } : {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return projected;
|
|
28
|
+
});
|
|
29
|
+
const snapshot = {
|
|
30
|
+
version: 1,
|
|
31
|
+
source: input.source,
|
|
32
|
+
reason: input.reason,
|
|
33
|
+
refreshedAt: input.refreshedAt ?? new Date().toISOString(),
|
|
34
|
+
tasks
|
|
35
|
+
};
|
|
36
|
+
mkdirSync(stateDir(projectRoot), { recursive: true });
|
|
37
|
+
writeFileSync(projectionPath(projectRoot), JSON.stringify(snapshot, null, 2), "utf8");
|
|
38
|
+
return snapshot;
|
|
39
|
+
}
|
|
40
|
+
async function refreshTaskProjection(projectRoot, input) {
|
|
41
|
+
const tasks = typeof input.tasks === "function" ? await input.tasks() : input.tasks;
|
|
42
|
+
const activeRuns = typeof input.activeRuns === "function" ? await input.activeRuns() : input.activeRuns;
|
|
43
|
+
return writeTaskProjection(projectRoot, {
|
|
44
|
+
source: input.source,
|
|
45
|
+
reason: input.reason ?? "refresh",
|
|
46
|
+
tasks,
|
|
47
|
+
activeRuns
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// packages/server/src/server-helpers/github-reconciler.ts
|
|
52
|
+
function createGitHubTaskReconciler(input) {
|
|
53
|
+
let timer = null;
|
|
54
|
+
const intervalMs = Math.max(1000, Math.trunc(input.intervalMs ?? 60000));
|
|
55
|
+
const tick = async (reason = "poll") => {
|
|
56
|
+
const projection = await refreshTaskProjection(input.projectRoot, {
|
|
57
|
+
source: input.source ?? "github-issues",
|
|
58
|
+
reason,
|
|
59
|
+
tasks: input.listTasks,
|
|
60
|
+
activeRuns: input.listActiveRuns
|
|
61
|
+
});
|
|
62
|
+
input.onRefresh?.(projection);
|
|
63
|
+
return projection;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
tick,
|
|
67
|
+
start() {
|
|
68
|
+
if (timer)
|
|
69
|
+
return;
|
|
70
|
+
timer = setInterval(() => {
|
|
71
|
+
tick("poll").catch(() => {});
|
|
72
|
+
}, intervalMs);
|
|
73
|
+
if (typeof timer.unref === "function")
|
|
74
|
+
timer.unref();
|
|
75
|
+
},
|
|
76
|
+
stop() {
|
|
77
|
+
if (!timer)
|
|
78
|
+
return;
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
timer = null;
|
|
81
|
+
},
|
|
82
|
+
isRunning() {
|
|
83
|
+
return timer !== null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
createGitHubTaskReconciler
|
|
89
|
+
};
|