Skip to content

Enable GitAuto forge to call GitAuto with payload #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions src/frontend/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,21 @@ const App = () => {
}, [context]);

// Get Jira issue ID
const [issueId, setIssueId] = useState(null);
const [issueDetails, setIssueDetails] = useState(null);
useEffect(() => {
if (context) setIssueId(context.extension.issue.id);
const fetchIssueDetails = async () => {
if (!context?.extension?.issue?.id) return;
try {
const details = await invoke("getIssueDetails", {
issueId: context.extension.issue.id,
});
setIssueDetails(details);
} catch (error) {
console.error("Error fetching issue details:", error);
}
};

fetchIssueDetails();
}, [context]);

// Get corresponding GitHub repositories from Supabase
Expand All @@ -47,29 +59,33 @@ const App = () => {

// Handle selected repository
const [isLoading, setIsLoading] = useState(true);
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedRepo, setSelectedRepo] = useState({ label: "", value: "" });
useEffect(() => {
if (!cloudId || !projectId) return;
const loadSavedRepo = async () => {
setIsLoading(true);
try {
const savedRepo = await invoke("getStoredRepo", { cloudId, projectId, issueId });
setSelectedRepo(savedRepo || "");
const savedRepo = await invoke("getStoredRepo", {
cloudId,
projectId,
issueId: issueDetails?.id,
});
setSelectedRepo(savedRepo || { label: "", value: "" });
} catch (error) {
console.error("Error loading saved repo:", error);
} finally {
setIsLoading(false);
}
};
loadSavedRepo();
}, [cloudId, projectId, issueId]);
}, [cloudId, projectId, issueDetails]);

// Save repository when selected
const handleRepoChange = async (value) => {
setSelectedRepo(value);
if (!cloudId || !projectId) return;
try {
await invoke("storeRepo", { cloudId, projectId, issueId, value });
await invoke("storeRepo", { cloudId, projectId, issueId: issueDetails?.id, value });
} catch (error) {
console.error("Error saving repo:", error);
}
Expand All @@ -84,7 +100,24 @@ const App = () => {
if (!checked || !selectedRepo) return;
setIsTriggering(true);
try {
await invoke("triggerGitAuto", { cloudId, projectId, issueId, selectedRepo });
const [ownerName, repoName] = selectedRepo.value.split("/");
const repoData = githubRepos.find(
(repo) => repo.github_owner_name === ownerName && repo.github_repo_name === repoName
);

await invoke("triggerGitAuto", {
cloudId,
projectId,
...issueDetails,
owner: {
id: repoData?.github_owner_id,
name: ownerName,
},
repo: {
id: repoData?.github_repo_id,
name: repoName,
},
});
} catch (error) {
console.error("Error triggering GitAuto:", error);
} finally {
Expand All @@ -99,7 +132,7 @@ const App = () => {
<Select
value={selectedRepo}
onChange={handleRepoChange}
options={githubRepos.map((repo) => ({ label: repo, value: repo }))}
options={githubRepos.map((repo) => ({ label: repo, value: repo }))} // must be this format
isDisabled={isLoading}
placeholder="Select a repository"
/>
Expand Down
103 changes: 88 additions & 15 deletions src/resolvers/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Resolver from "@forge/resolver";
import forge from "@forge/api";
import { storage } from "@forge/api";
import { route, storage } from "@forge/api";

const resolver = new Resolver();

Expand All @@ -14,7 +14,7 @@ resolver.define("getGithubRepos", async ({ payload }) => {

// https://supabase.com/docs/guides/api/sql-to-rest
const queryParams = new URLSearchParams({
select: "*",
select: "github_owner_name,github_repo_name,github_owner_id",
jira_site_id: `eq.${cloudId}`,
jira_project_id: `eq.${projectId}`,
}).toString();
Expand Down Expand Up @@ -51,26 +51,99 @@ resolver.define("storeRepo", async ({ payload }) => {
return await storage.set(key, value);
});

// Trigger GitAuto by calling the FastAPI endpoint
resolver.define("triggerGitAuto", async ({ payload }) => {
const { cloudId, projectId, issueId, selectedRepo } = payload;

// Determine the API endpoint based on environment
const endpoint = process.env.GITAUTO_URL + "/webhook";
console.log("Endpoint", endpoint);
const endpoint = process.env.GITAUTO_URL + "/jira-webhook";
const response = await forge.fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cloudId,
projectId,
issueId,
repository: selectedRepo,
}),
body: JSON.stringify({ ...payload }),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): Consider validating payload fields before sending to external service

Spreading the entire payload without validation could lead to sending unexpected data. Consider explicitly mapping required fields.

Suggested implementation:

    body: JSON.stringify({
      issueId: payload.issueId,
      repository: payload.repository,
    }),

For even better validation, you might want to add:

  1. Type checking before the JSON.stringify:
if (!payload.issueId || !payload.repository) {
  throw new Error('Missing required fields: issueId and repository are required');
}
  1. Consider using a validation library like Joi or Zod if you want more robust validation
  2. Add TypeScript types if the project uses TypeScript

});

if (!response.ok) throw new Error(`Failed to trigger GitAuto: ${response.status}`);

return await response.json();
});

// Convert Atlassian Document Format to Markdown
const adfToMarkdown = (adf) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting common text content logic and flattening the code structure in the ADF to Markdown conversion.

The adfToMarkdown function can be simplified by extracting common patterns and reducing nesting. Here's a cleaner approach:

const getTextContent = (content) => {
  if (!content) return '';
  return content.map(item => item.text || '').join('');
};

const adfToMarkdown = (adf) => {
  if (!adf?.content) return '';

  return adf.content.map((block) => {
    const content = block.content || [];

    switch (block.type) {
      case 'paragraph':
        return getTextContent(content) + '\n\n';
      case 'heading':
        const level = block.attrs?.level || 1;
        return '#'.repeat(level) + ' ' + getTextContent(content) + '\n\n';
      case 'bulletList':
        return content.map(item => `- ${getTextContent(item.content)}`).join('\n') + '\n\n';
      case 'orderedList':
        return content.map((item, i) => `${i + 1}. ${getTextContent(item.content)}`).join('\n') + '\n\n';
      case 'codeBlock':
        return '```\n' + getTextContent(content) + '\n```\n\n';
      default:
        return '';
    }
  }).join('').trim();
};

This refactoring:

  1. Extracts common text content mapping into getTextContent
  2. Reduces nesting by flattening the structure
  3. Maintains null safety while being more readable
  4. Eliminates redundant optional chaining

if (!adf || !adf.content) return "";

return adf.content
.map((block) => {
switch (block.type) {
case "paragraph":
return block.content?.map((item) => item.text || "").join("") + "\n\n";
case "heading":
const level = block.attrs?.level || 1;
const hashes = "#".repeat(level);
return `${hashes} ${block.content?.map((item) => item.text || "").join("")}\n\n`;
case "bulletList":
return (
block.content
?.map((item) => `- ${item.content?.map((subItem) => subItem.text || "").join("")}`)
.join("\n") + "\n\n"
);
case "orderedList":
return (
block.content
?.map(
(item, index) =>
`${index + 1}. ${item.content?.map((subItem) => subItem.text || "").join("")}`
)
.join("\n") + "\n\n"
);
case "codeBlock":
return `\`\`\`\n${block.content?.map((item) => item.text || "").join("")}\n\`\`\`\n\n`;
default:
return "";
}
})
.join("")
.trim();
};

// Get issue details from Jira
// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-get
resolver.define("getIssueDetails", async ({ payload }) => {
const { issueId } = payload;
const response = await forge.asApp().requestJira(route`/rest/api/3/issue/${issueId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error(`Failed to fetch issue details: ${response.status}`);
const data = await response.json();
// console.log("Jira issue details:", data);

// Format comments into readable text list
const comments =
data.fields.comment?.comments?.map((comment) => {
const timestamp = new Date(comment.created).toLocaleString();
return `${comment.author.displayName} (${timestamp}):\n${adfToMarkdown(comment.body)}`;
}) || [];

return {
// project: {
// id: data.fields.project.id,
// key: data.fields.project.key,
// name: data.fields.project.name,
// },
issue: {
id: data.id,
key: data.key,
title: data.fields.summary,
body: adfToMarkdown(data.fields.description),
comments: comments,
},
creator: {
id: data.fields.creator.accountId,
displayName: data.fields.creator.displayName,
email: data.fields.creator.emailAddress,
},
reporter: {
id: data.fields.reporter.accountId,
displayName: data.fields.reporter.displayName,
email: data.fields.reporter.emailAddress,
},
};
});

export const handler = resolver.getDefinitions();
Loading