@fink-andreas/pi-linear-tools 0.4.1 → 0.4.3

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/src/handlers.js CHANGED
@@ -13,20 +13,35 @@ import {
13
13
  updateIssue,
14
14
  createIssue,
15
15
  fetchProjects,
16
+ fetchProjectDetails,
16
17
  fetchTeams,
17
18
  resolveProjectRef,
18
19
  resolveTeamRef,
19
20
  getTeamWorkflowStates,
20
21
  fetchIssueDetails,
22
+ fetchIssueActivity,
21
23
  formatIssueAsMarkdown,
24
+ formatIssueActivityAsMarkdown,
22
25
  fetchIssuesByProject,
23
26
  fetchProjectMilestones,
24
27
  fetchMilestoneDetails,
25
28
  createProjectMilestone,
26
29
  updateProjectMilestone,
27
30
  deleteProjectMilestone,
31
+ createProject,
32
+ updateProject,
33
+ deleteProject,
34
+ archiveProject,
35
+ unarchiveProject,
36
+ fetchProjectUpdates,
37
+ fetchProjectUpdateDetails,
38
+ createProjectUpdate,
39
+ updateProjectUpdate,
40
+ archiveProjectUpdate,
41
+ unarchiveProjectUpdate,
28
42
  deleteIssue,
29
43
  withHandlerErrorHandling,
44
+ getViewer,
30
45
  } from './linear.js';
31
46
  import { debug } from './logger.js';
32
47
 
@@ -43,6 +58,17 @@ function ensureNonEmpty(value, fieldName) {
43
58
  return text;
44
59
  }
45
60
 
61
+ function parseRefList(value) {
62
+ if (value === undefined || value === null) return [];
63
+ if (Array.isArray(value)) {
64
+ return value.map((item) => String(item || '').trim()).filter(Boolean);
65
+ }
66
+ return String(value)
67
+ .split(',')
68
+ .map((item) => item.trim())
69
+ .filter(Boolean);
70
+ }
71
+
46
72
  // ===== GIT OPERATIONS (for issue start) =====
47
73
 
48
74
  /**
@@ -130,6 +156,14 @@ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'sw
130
156
 
131
157
  /**
132
158
  * List issues in a project
159
+ * @param {LinearClient} client - Linear SDK client
160
+ * @param {Object} params - Parameters
161
+ * @param {string} [params.project] - Project name or ID
162
+ * @param {string[]} [params.states] - State names to filter by
163
+ * @param {string} [params.assignee] - "me" or "all" for assignee filtering
164
+ * @param {string} [params.team] - Team key or ID to filter by
165
+ * @param {number} [params.limit] - Maximum results (default: 20)
166
+ * @returns {Promise<{content: Array, details: Object}>}
133
167
  */
134
168
  export async function executeIssueList(client, params) {
135
169
  return withHandlerErrorHandling(async () => {
@@ -142,12 +176,20 @@ export async function executeIssueList(client, params) {
142
176
 
143
177
  let assigneeId = null;
144
178
  if (params.assignee === 'me') {
145
- const viewer = await client.viewer;
179
+ const viewer = await getViewer(client);
146
180
  assigneeId = viewer.id;
147
181
  }
148
182
 
183
+ // Resolve team if provided
184
+ let teamId = null;
185
+ if (params.team) {
186
+ const team = await resolveTeamRef(client, params.team);
187
+ teamId = team.id;
188
+ }
189
+
149
190
  const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
150
191
  assigneeId,
192
+ teamId,
151
193
  limit: params.limit || 20,
152
194
  });
153
195
 
@@ -209,6 +251,28 @@ export async function executeIssueView(client, params) {
209
251
  };
210
252
  }
211
253
 
254
+ export async function executeIssueActivity(client, params) {
255
+ const issue = ensureNonEmpty(params.issue, 'issue');
256
+ const activityData = await fetchIssueActivity(client, issue, {
257
+ limit: params.limit || 25,
258
+ includeArchived: params.includeArchived === true,
259
+ });
260
+ const markdown = formatIssueActivityAsMarkdown(activityData, {
261
+ limit: params.limit,
262
+ });
263
+
264
+ return {
265
+ content: [{ type: 'text', text: markdown }],
266
+ details: {
267
+ issueId: activityData.issue.id,
268
+ identifier: activityData.issue.identifier,
269
+ title: activityData.issue.title,
270
+ activityCount: activityData.activity.length,
271
+ url: activityData.issue.url,
272
+ },
273
+ };
274
+ }
275
+
212
276
  /**
213
277
  * Create a new issue
214
278
  */
@@ -260,7 +324,7 @@ export async function executeIssueCreate(client, params, options = {}) {
260
324
  }
261
325
 
262
326
  if (params.assignee === 'me') {
263
- const viewer = await client.viewer;
327
+ const viewer = await getViewer(client);
264
328
  createInput.assigneeId = viewer.id;
265
329
  } else if (params.assignee) {
266
330
  createInput.assigneeId = params.assignee;
@@ -358,7 +422,7 @@ export async function executeIssueUpdate(client, params) {
358
422
 
359
423
  // Handle assignee parameter
360
424
  if (params.assignee === 'me') {
361
- const viewer = await client.viewer;
425
+ const viewer = await getViewer(client);
362
426
  updatePatch.assigneeId = viewer.id;
363
427
  } else if (params.assignee) {
364
428
  updatePatch.assigneeId = params.assignee;
@@ -413,8 +477,12 @@ export async function executeIssueUpdate(client, params) {
413
477
  ? ` (${changeSummaryParts.join(', ')})`
414
478
  : '';
415
479
 
480
+ const rateLimitNote = result.usedRateLimitFallback
481
+ ? '\n\n_Note: update succeeded, but detailed issue refresh was rate-limited. Some returned fields may be partial until rate limit resets._'
482
+ : '';
483
+
416
484
  return toTextResult(
417
- `Updated issue ${result.issue.identifier}${suffix}`,
485
+ `Updated issue ${result.issue.identifier}${suffix}${rateLimitNote}`,
418
486
  {
419
487
  issueId: result.issue.id,
420
488
  identifier: result.issue.identifier,
@@ -422,6 +490,7 @@ export async function executeIssueUpdate(client, params) {
422
490
  state: result.issue.state,
423
491
  priority: result.issue.priority,
424
492
  projectMilestone: result.issue.projectMilestone,
493
+ usedRateLimitFallback: !!result.usedRateLimitFallback,
425
494
  }
426
495
  );
427
496
  }
@@ -529,6 +598,362 @@ export async function executeProjectList(client) {
529
598
  });
530
599
  }
531
600
 
601
+ export async function executeProjectView(client, params) {
602
+ return withHandlerErrorHandling(async () => {
603
+ const projectRef = ensureNonEmpty(params.project, 'project');
604
+ const project = await fetchProjectDetails(client, projectRef);
605
+
606
+ const lines = [`# Project: ${project.name}`];
607
+ const metaParts = [];
608
+
609
+ if (project.status?.name) metaParts.push(`**Status:** ${project.status.name}`);
610
+ if (project.health) metaParts.push(`**Health:** ${project.health}`);
611
+ if (project.progress !== undefined && project.progress !== null) metaParts.push(`**Progress:** ${project.progress}%`);
612
+ if (project.priority !== undefined && project.priority !== null) metaParts.push(`**Priority:** ${project.priority}`);
613
+ if (project.startDate) metaParts.push(`**Start:** ${project.startDate}`);
614
+ if (project.targetDate) metaParts.push(`**Target:** ${project.targetDate}`);
615
+
616
+ if (metaParts.length > 0) {
617
+ lines.push('');
618
+ lines.push(metaParts.join(' | '));
619
+ }
620
+
621
+ const teamLabel = project.teams.length > 0
622
+ ? project.teams.map((team) => `\`${team.key}\``).join(', ')
623
+ : 'None';
624
+ const leadLabel = project.lead?.displayName || project.lead?.name || 'Unassigned';
625
+
626
+ lines.push('');
627
+ lines.push(`**Teams:** ${teamLabel}`);
628
+ lines.push(`**Lead:** ${leadLabel}`);
629
+
630
+ if (project.url) {
631
+ lines.push(`**URL:** ${project.url}`);
632
+ }
633
+
634
+ if (project.description) {
635
+ lines.push('');
636
+ lines.push(project.description);
637
+ }
638
+
639
+ if (project.content) {
640
+ lines.push('');
641
+ if (project.description) {
642
+ lines.push('## Content');
643
+ lines.push('');
644
+ }
645
+ lines.push(project.content);
646
+ }
647
+
648
+ if (project.projectMilestones.length > 0) {
649
+ lines.push('');
650
+ lines.push(`## Milestones (${project.projectMilestones.length})`);
651
+ lines.push('');
652
+
653
+ for (const milestone of project.projectMilestones) {
654
+ const progressLabel = milestone.progress !== undefined && milestone.progress !== null
655
+ ? `${milestone.progress}%`
656
+ : 'N/A';
657
+ const targetLabel = milestone.targetDate ? ` → ${milestone.targetDate}` : '';
658
+ lines.push(`- **${milestone.name}** _[${milestone.status}]_ (${progressLabel})${targetLabel}`);
659
+ }
660
+ }
661
+
662
+ return toTextResult(lines.join('\n'), {
663
+ projectId: project.id,
664
+ name: project.name,
665
+ status: project.status,
666
+ teamCount: project.teams.length,
667
+ milestoneCount: project.projectMilestones.length,
668
+ url: project.url,
669
+ });
670
+ }, 'executeProjectView');
671
+ }
672
+
673
+ export async function executeProjectCreate(client, params) {
674
+ return withHandlerErrorHandling(async () => {
675
+ const name = ensureNonEmpty(params.name, 'name');
676
+ const teamRefs = parseRefList(params.teams ?? params.team);
677
+
678
+ if (teamRefs.length === 0) {
679
+ throw new Error('Missing required field: teams');
680
+ }
681
+
682
+ const teams = await Promise.all(teamRefs.map((teamRef) => resolveTeamRef(client, teamRef)));
683
+
684
+ let leadId;
685
+ if (params.lead === 'me') {
686
+ const viewer = await getViewer(client);
687
+ leadId = viewer.id;
688
+ } else if (params.lead) {
689
+ leadId = params.lead;
690
+ }
691
+
692
+ const project = await createProject(client, {
693
+ name,
694
+ teamIds: teams.map((team) => team.id),
695
+ description: params.description,
696
+ leadId,
697
+ priority: params.priority,
698
+ color: params.color,
699
+ icon: params.icon,
700
+ startDate: params.startDate,
701
+ targetDate: params.targetDate,
702
+ });
703
+
704
+ return toTextResult(
705
+ `Created project **${project.name}** for ${teams.map((team) => team.key).join(', ')}`,
706
+ {
707
+ projectId: project.id,
708
+ name: project.name,
709
+ teams: project.teams,
710
+ status: project.status,
711
+ url: project.url,
712
+ }
713
+ );
714
+ }, 'executeProjectCreate');
715
+ }
716
+
717
+ export async function executeProjectUpdate(client, params) {
718
+ return withHandlerErrorHandling(async () => {
719
+ const projectRef = ensureNonEmpty(params.project, 'project');
720
+ const patch = {
721
+ name: params.name,
722
+ description: params.description,
723
+ priority: params.priority,
724
+ color: params.color,
725
+ icon: params.icon,
726
+ startDate: params.startDate,
727
+ targetDate: params.targetDate,
728
+ };
729
+
730
+ if (params.lead === 'me') {
731
+ const viewer = await getViewer(client);
732
+ patch.leadId = viewer.id;
733
+ } else if (params.lead === 'none') {
734
+ patch.leadId = null;
735
+ } else if (params.lead !== undefined) {
736
+ patch.leadId = params.lead;
737
+ }
738
+
739
+ if (params.teams !== undefined || params.team !== undefined) {
740
+ const teamRefs = parseRefList(params.teams ?? params.team);
741
+ if (teamRefs.length === 0) {
742
+ throw new Error('At least one team is required when updating teams');
743
+ }
744
+ const teams = await Promise.all(teamRefs.map((teamRef) => resolveTeamRef(client, teamRef)));
745
+ patch.teamIds = teams.map((team) => team.id);
746
+ }
747
+
748
+ const result = await updateProject(client, projectRef, patch);
749
+
750
+ return toTextResult(
751
+ `Updated project **${result.project.name}** (${result.changed.join(', ')})`,
752
+ {
753
+ projectId: result.project.id,
754
+ name: result.project.name,
755
+ changed: result.changed,
756
+ status: result.project.status,
757
+ }
758
+ );
759
+ }, 'executeProjectUpdate');
760
+ }
761
+
762
+ export async function executeProjectDelete(client, params) {
763
+ return withHandlerErrorHandling(async () => {
764
+ const projectRef = ensureNonEmpty(params.project, 'project');
765
+ const result = await deleteProject(client, projectRef);
766
+
767
+ return toTextResult(
768
+ `Deleted project **${result.name}** \`${result.projectId}\``,
769
+ {
770
+ projectId: result.projectId,
771
+ name: result.name,
772
+ success: result.success,
773
+ }
774
+ );
775
+ }, 'executeProjectDelete');
776
+ }
777
+
778
+ export async function executeProjectArchive(client, params) {
779
+ return withHandlerErrorHandling(async () => {
780
+ const projectRef = ensureNonEmpty(params.project, 'project');
781
+ const result = await archiveProject(client, projectRef);
782
+
783
+ return toTextResult(
784
+ `Archived project **${result.name || result.entity?.name || result.projectId}**`,
785
+ {
786
+ projectId: result.projectId,
787
+ name: result.name || result.entity?.name || null,
788
+ success: result.success,
789
+ }
790
+ );
791
+ }, 'executeProjectArchive');
792
+ }
793
+
794
+ export async function executeProjectUnarchive(client, params) {
795
+ return withHandlerErrorHandling(async () => {
796
+ const projectRef = ensureNonEmpty(params.project, 'project');
797
+ const result = await unarchiveProject(client, projectRef);
798
+
799
+ return toTextResult(
800
+ `Unarchived project **${result.project.name}**`,
801
+ {
802
+ projectId: result.project.id,
803
+ name: result.project.name,
804
+ success: result.success,
805
+ }
806
+ );
807
+ }, 'executeProjectUnarchive');
808
+ }
809
+
810
+ // ===== PROJECT UPDATE HANDLERS =====
811
+
812
+ export async function executeProjectUpdateList(client, params) {
813
+ return withHandlerErrorHandling(async () => {
814
+ const projectRef = ensureNonEmpty(params.project, 'project');
815
+ const { project, updates } = await fetchProjectUpdates(client, projectRef, {
816
+ limit: params.limit ?? 10,
817
+ includeArchived: params.includeArchived === true,
818
+ });
819
+
820
+ if (updates.length === 0) {
821
+ return toTextResult(`No project updates found for "${project.name}"`, {
822
+ projectId: project.id,
823
+ projectName: project.name,
824
+ updateCount: 0,
825
+ });
826
+ }
827
+
828
+ const lines = [`## Project updates for "${project.name}" (${updates.length})`, ''];
829
+ for (const update of updates) {
830
+ const author = update.user?.displayName || update.user?.name || 'Unknown';
831
+ const createdAt = update.createdAt ? String(update.createdAt).slice(0, 10) : 'unknown date';
832
+ const healthLabel = update.health ? ` [${update.health}]` : '';
833
+ const archivedLabel = update.archivedAt ? ' [archived]' : '';
834
+ const preview = update.body.replace(/\s+/g, ' ').trim().slice(0, 120);
835
+ lines.push(`- **${update.id}**${healthLabel}${archivedLabel} by ${author} on ${createdAt}`);
836
+ if (preview) {
837
+ lines.push(` ${preview}${update.body.length > 120 ? '...' : ''}`);
838
+ }
839
+ }
840
+
841
+ return toTextResult(lines.join('\n'), {
842
+ projectId: project.id,
843
+ projectName: project.name,
844
+ updateCount: updates.length,
845
+ });
846
+ }, 'executeProjectUpdateList');
847
+ }
848
+
849
+ export async function executeProjectUpdateView(client, params) {
850
+ return withHandlerErrorHandling(async () => {
851
+ const projectUpdateId = ensureNonEmpty(params.projectUpdate, 'projectUpdate');
852
+ const projectUpdate = await fetchProjectUpdateDetails(client, projectUpdateId);
853
+
854
+ const lines = [`# Project update ${projectUpdate.id}`];
855
+ const meta = [];
856
+ if (projectUpdate.project?.name) meta.push(`**Project:** ${projectUpdate.project.name}`);
857
+ if (projectUpdate.health) meta.push(`**Health:** ${projectUpdate.health}`);
858
+ if (projectUpdate.user?.displayName || projectUpdate.user?.name) meta.push(`**Author:** ${projectUpdate.user?.displayName || projectUpdate.user?.name}`);
859
+ if (projectUpdate.createdAt) meta.push(`**Created:** ${String(projectUpdate.createdAt).slice(0, 10)}`);
860
+ if (projectUpdate.archivedAt) meta.push(`**Archived:** ${String(projectUpdate.archivedAt).slice(0, 10)}`);
861
+ if (meta.length > 0) {
862
+ lines.push('');
863
+ lines.push(meta.join(' | '));
864
+ }
865
+ if (projectUpdate.url) {
866
+ lines.push('');
867
+ lines.push(`**URL:** ${projectUpdate.url}`);
868
+ }
869
+ if (projectUpdate.body) {
870
+ lines.push('');
871
+ lines.push(projectUpdate.body);
872
+ }
873
+
874
+ return toTextResult(lines.join('\n'), {
875
+ projectUpdateId: projectUpdate.id,
876
+ projectId: projectUpdate.project?.id || null,
877
+ projectName: projectUpdate.project?.name || null,
878
+ health: projectUpdate.health,
879
+ archivedAt: projectUpdate.archivedAt,
880
+ });
881
+ }, 'executeProjectUpdateView');
882
+ }
883
+
884
+ export async function executeProjectUpdateCreate(client, params) {
885
+ return withHandlerErrorHandling(async () => {
886
+ const projectRef = ensureNonEmpty(params.project, 'project');
887
+ const resolved = await resolveProjectRef(client, projectRef);
888
+ const projectUpdate = await createProjectUpdate(client, {
889
+ projectId: resolved.id,
890
+ body: params.body,
891
+ health: params.health,
892
+ isDiffHidden: params.isDiffHidden,
893
+ });
894
+
895
+ return toTextResult(
896
+ `Created project update **${projectUpdate.id}** for "${resolved.name}"`,
897
+ {
898
+ projectUpdateId: projectUpdate.id,
899
+ projectId: resolved.id,
900
+ projectName: resolved.name,
901
+ health: projectUpdate.health,
902
+ }
903
+ );
904
+ }, 'executeProjectUpdateCreate');
905
+ }
906
+
907
+ export async function executeProjectUpdateUpdate(client, params) {
908
+ return withHandlerErrorHandling(async () => {
909
+ const projectUpdateId = ensureNonEmpty(params.projectUpdate, 'projectUpdate');
910
+ const result = await updateProjectUpdate(client, projectUpdateId, {
911
+ body: params.body,
912
+ health: params.health,
913
+ isDiffHidden: params.isDiffHidden,
914
+ });
915
+
916
+ return toTextResult(
917
+ `Updated project update **${result.projectUpdate.id}** (${result.changed.join(', ')})`,
918
+ {
919
+ projectUpdateId: result.projectUpdate.id,
920
+ changed: result.changed,
921
+ health: result.projectUpdate.health,
922
+ }
923
+ );
924
+ }, 'executeProjectUpdateUpdate');
925
+ }
926
+
927
+ export async function executeProjectUpdateArchive(client, params) {
928
+ return withHandlerErrorHandling(async () => {
929
+ const projectUpdateId = ensureNonEmpty(params.projectUpdate, 'projectUpdate');
930
+ const result = await archiveProjectUpdate(client, projectUpdateId);
931
+
932
+ return toTextResult(
933
+ `Archived project update **${projectUpdateId}**`,
934
+ {
935
+ projectUpdateId,
936
+ success: result.success,
937
+ }
938
+ );
939
+ }, 'executeProjectUpdateArchive');
940
+ }
941
+
942
+ export async function executeProjectUpdateUnarchive(client, params) {
943
+ return withHandlerErrorHandling(async () => {
944
+ const projectUpdateId = ensureNonEmpty(params.projectUpdate, 'projectUpdate');
945
+ const result = await unarchiveProjectUpdate(client, projectUpdateId);
946
+
947
+ return toTextResult(
948
+ `Unarchived project update **${result.projectUpdate.id}**`,
949
+ {
950
+ projectUpdateId: result.projectUpdate.id,
951
+ success: result.success,
952
+ }
953
+ );
954
+ }, 'executeProjectUpdateUnarchive');
955
+ }
956
+
532
957
  // ===== TEAM HANDLERS =====
533
958
 
534
959
  /**
@@ -28,6 +28,10 @@ function getTrackerKey(apiKey) {
28
28
  return apiKey || 'default';
29
29
  }
30
30
 
31
+ function getTrackerKeyFromClient(client) {
32
+ return client?.__piLinearTrackerKey || client?.apiKey || 'default';
33
+ }
34
+
31
35
  function getRequestMetric(trackerKey) {
32
36
  let metric = requestMetrics.get(trackerKey);
33
37
  if (!metric) {
@@ -124,7 +128,7 @@ export function markRateLimited(resetAt) {
124
128
  * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null}}
125
129
  */
126
130
  export function getClientRateLimit(client) {
127
- const trackerData = rateLimitTracker.get(client.apiKey || 'default');
131
+ const trackerData = rateLimitTracker.get(getTrackerKeyFromClient(client));
128
132
  if (trackerData) {
129
133
  return {
130
134
  remaining: trackerData.remaining,
@@ -140,7 +144,7 @@ export function getClientRateLimit(client) {
140
144
  * Expose per-client request counters for diagnostics
141
145
  */
142
146
  export function getClientRequestMetrics(client) {
143
- const key = client?.apiKey || 'default';
147
+ const key = getTrackerKeyFromClient(client);
144
148
  const metric = requestMetrics.get(key);
145
149
  if (!metric) {
146
150
  return {
@@ -227,6 +231,7 @@ export function createLinearClient(auth) {
227
231
  const client = new LinearClient(clientConfig);
228
232
 
229
233
  const trackerKey = getTrackerKey(apiKey);
234
+ client.__piLinearTrackerKey = trackerKey;
230
235
  if (!rateLimitTracker.has(trackerKey)) {
231
236
  rateLimitTracker.set(trackerKey, { remaining: 5000, resetAt: Date.now() + 3600000 });
232
237
  }